ipakit 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.
ipakit/__init__.py ADDED
@@ -0,0 +1,522 @@
1
+ """ipakit - IPA phonetic features library.
2
+
3
+ Simple API:
4
+ import ipakit as ipa
5
+
6
+ ipa.distance("p", "b") # 0.043
7
+ ipa.features("p") # {'manner': 'plosive', ...}
8
+ ipa.to_cmu("ˈhɛloʊ") # ['HH', 'EH1', 'L', 'OW0']
9
+ ipa.to_ipa(["HH", "EH1", "L"]) # 'hˈɛl'
10
+ ipa.tokenize("t͡ʃe͡ɪnd͡ʒ") # ['t͡ʃ', 'e͡ɪ', 'n', 'd͡ʒ']
11
+ ipa.normalize("tʃ eɪ n dʒ") # 't͡ʃe͡ɪnd͡ʒ'
12
+
13
+ Class API:
14
+ from ipakit import IPAFeatures, CMUMapper
15
+
16
+ Converter return types follow the target format: converters to a token-oriented
17
+ phone set (``to_cmu``, ``to_timit``) return ``list[str]``, while converters to a
18
+ transcription string (``ipa_to_xsampa``, ``to_kirshenbaum``) return ``str``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import functools
24
+ from pathlib import Path
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ # Re-export classes
29
+ from .constants import (
30
+ DATA_DIR,
31
+ DEFAULT_CMU_MAP,
32
+ DEFAULT_IPA_FEATS,
33
+ PHONEMAPS_DIR,
34
+ TIE_BAR,
35
+ )
36
+ from .distance import WordDistanceResult
37
+ from .distance_model import DistanceModel
38
+ from .features import IPAFeatures
39
+ from .mapper import CMUMapper
40
+ from .models import Feature, Phone, PhoneMapping, Phoneset
41
+ from .phonemaps import (
42
+ from_kirshenbaum,
43
+ from_timit,
44
+ ipa_to_phonemap,
45
+ phonemap_to_ipa,
46
+ to_kirshenbaum,
47
+ to_timit,
48
+ )
49
+
50
+ # X-SAMPA string conversion lives in ipakit.xsampa, the single source of truth
51
+ # for the IPA <-> X-SAMPA table. Re-exported here for the flat module API.
52
+ from .xsampa import ipa_to_xsampa, xsampa_to_ipa
53
+
54
+ # =============================================================================
55
+ # Module-level API (lazy singletons)
56
+ # =============================================================================
57
+
58
+
59
+ @functools.lru_cache(maxsize=1)
60
+ def _get_ipa() -> IPAFeatures:
61
+ return IPAFeatures()
62
+
63
+
64
+ @functools.lru_cache(maxsize=1)
65
+ def _get_cmu() -> CMUMapper:
66
+ return CMUMapper()
67
+
68
+
69
+ @functools.lru_cache(maxsize=1)
70
+ def _get_default_model() -> DistanceModel:
71
+ return DistanceModel.global_(_get_ipa())
72
+
73
+
74
+ def load_ipa_features(xml_path: Path = DEFAULT_IPA_FEATS) -> IPAFeatures:
75
+ """Convenience function to load IPA features."""
76
+ return IPAFeatures(xml_path)
77
+
78
+
79
+ # --- Distance & Features ---
80
+
81
+
82
+ def distance(phone1: str, phone2: str) -> float:
83
+ """Compute phonetic distance between two IPA phones (0.0-1.0)."""
84
+ return _get_ipa().distance(phone1, phone2)
85
+
86
+
87
+ def word_distance(
88
+ ipa1: str,
89
+ ipa2: str,
90
+ weighted: bool = True,
91
+ return_alignment: bool = False,
92
+ ) -> WordDistanceResult:
93
+ """Compute phonetic edit distance between two IPA words.
94
+
95
+ Uses Levenshtein-style dynamic programming with phonetic feature costs.
96
+
97
+ Args:
98
+ ipa1: First IPA string
99
+ ipa2: Second IPA string
100
+ weighted: If True, use feature distance for substitution costs.
101
+ return_alignment: If True, include the alignment path in result.
102
+
103
+ Returns:
104
+ WordDistanceResult with distance, similarity, and optional alignment.
105
+
106
+ Examples:
107
+ >>> ipakit.word_distance("kæt", "kæd")
108
+ WordDistanceResult(distance=0.04..., similarity=0.98..., alignment=None)
109
+ >>> ipakit.word_distance("kæt", "dɒɡ")
110
+ WordDistanceResult(distance=..., similarity=..., alignment=None)
111
+ """
112
+ return _get_ipa().word_distance(
113
+ ipa1, ipa2, weighted=weighted, return_alignment=return_alignment
114
+ )
115
+
116
+
117
+ def word_similarity(ipa1: str, ipa2: str, weighted: bool = True) -> float:
118
+ """Compute phonetic similarity between two IPA words.
119
+
120
+ Returns a value from 0.0 (completely different) to 1.0 (identical).
121
+ Similarity = 1 - (edit_distance / max_length), with lower bound of 0.
122
+
123
+ Args:
124
+ ipa1: First IPA string
125
+ ipa2: Second IPA string
126
+ weighted: If True, use feature distance for substitution costs.
127
+
128
+ Examples:
129
+ >>> ipakit.word_similarity("kæt", "kæd")
130
+ 0.98...
131
+ >>> ipakit.word_similarity("kæt", "dɒɡ") # weighted subs are cheap (shared features)
132
+ 0.9...
133
+ """
134
+ return _get_ipa().word_similarity(ipa1, ipa2, weighted=weighted)
135
+
136
+
137
+ def normalized_distance(phone1: str, phone2: str) -> float:
138
+ """CDF-renormalized distance (percentile within the bundled IPA inventory)."""
139
+ return _get_default_model().distance(phone1, phone2)
140
+
141
+
142
+ def confusability(phone1: str, phone2: str) -> float:
143
+ """Normalized confusability (percentile similarity) in the bundled IPA inventory.
144
+
145
+ The complement of :func:`normalized_distance`; 1.0 for identical phones.
146
+ For an inventory-scoped model, build one with :func:`distance_model`.
147
+
148
+ Examples:
149
+ >>> round(ipakit.confusability("p", "b"), 3)
150
+ 0.845
151
+ >>> ipakit.confusability("p", "p")
152
+ 1.0
153
+ """
154
+ return _get_default_model().confusability(phone1, phone2)
155
+
156
+
157
+ def distance_model(
158
+ reference: Phoneset | list[str] | None = None,
159
+ *,
160
+ gamma: float = 1.0,
161
+ sub_mode: str = "simple",
162
+ insert_cost: float = 1.0,
163
+ delete_cost: float = 1.0,
164
+ threshold: float | None = None,
165
+ max_length_ratio: float | None = None,
166
+ ) -> DistanceModel:
167
+ """Build a distribution-aware distance model over a reference inventory.
168
+
169
+ ``reference=None`` uses the bundled global IPA inventory (default).
170
+ """
171
+ ipa = _get_ipa()
172
+ if reference is None:
173
+ return DistanceModel.global_(
174
+ ipa,
175
+ gamma=gamma,
176
+ sub_mode=sub_mode,
177
+ insert_cost=insert_cost,
178
+ delete_cost=delete_cost,
179
+ threshold=threshold,
180
+ max_length_ratio=max_length_ratio,
181
+ )
182
+ ps = reference if isinstance(reference, Phoneset) else Phoneset.from_list(reference)
183
+ return DistanceModel.for_phoneset(
184
+ ipa,
185
+ ps,
186
+ gamma=gamma,
187
+ sub_mode=sub_mode,
188
+ insert_cost=insert_cost,
189
+ delete_cost=delete_cost,
190
+ threshold=threshold,
191
+ max_length_ratio=max_length_ratio,
192
+ )
193
+
194
+
195
+ def features(phone: str, with_defaults: bool = True) -> dict[str, str]:
196
+ """Get phonetic features for an IPA phone."""
197
+ return _get_ipa().get_features(phone, with_defaults=with_defaults)
198
+
199
+
200
+ def features_from_cmu(
201
+ cmu_symbols: list[str], with_defaults: bool = True
202
+ ) -> list[dict[str, str]]:
203
+ """Get feature bundles from CMU ARPABET symbols."""
204
+ ipa_str = _get_cmu().cmu_to_ipa(cmu_symbols)
205
+ return _get_ipa().compose(ipa_str, with_defaults=with_defaults)
206
+
207
+
208
+ def features_from_xsampa(
209
+ xsampa: str, with_defaults: bool = True
210
+ ) -> list[dict[str, str]]:
211
+ """Get feature bundles from X-SAMPA string."""
212
+ ipa_str = xsampa_to_ipa(xsampa)
213
+ return _get_ipa().compose(ipa_str, with_defaults=with_defaults)
214
+
215
+
216
+ # --- CMU ARPABET Conversion ---
217
+
218
+
219
+ def to_cmu(
220
+ ipa_string: str,
221
+ with_stress: bool = True,
222
+ include_extras: bool = False,
223
+ strict: bool = False,
224
+ ) -> list[str]:
225
+ """Convert IPA string to list of CMU ARPABET symbols.
226
+
227
+ With ``strict=True``, raise ``ValueError`` on unconvertible phones.
228
+ """
229
+ return _get_cmu().ipa_to_cmu(
230
+ ipa_string,
231
+ with_stress=with_stress,
232
+ include_extras=include_extras,
233
+ strict=strict,
234
+ )
235
+
236
+
237
+ def from_cmu(
238
+ cmu_symbols: list[str], include_extras: bool = True, strict: bool = False
239
+ ) -> str:
240
+ """Convert list of CMU ARPABET symbols to IPA string.
241
+
242
+ With ``strict=True``, raise ``ValueError`` on unknown CMU symbols.
243
+
244
+ Examples:
245
+ >>> ipakit.from_cmu(["K", "AE1", "T"])
246
+ 'kˈæt'
247
+ """
248
+ return _get_cmu().cmu_to_ipa(
249
+ cmu_symbols, include_extras=include_extras, strict=strict
250
+ )
251
+
252
+
253
+ def to_ipa(
254
+ cmu_symbols: list[str], include_extras: bool = True, strict: bool = False
255
+ ) -> str:
256
+ """Alias for :func:`from_cmu` (CMU ARPABET -> IPA).
257
+
258
+ ``from_cmu`` is the canonical name -- it names the source format, matching
259
+ ``from_xsampa``/``from_timit``/``from_kirshenbaum``, which also produce IPA.
260
+ ``to_ipa`` is kept for backward compatibility.
261
+ """
262
+ return from_cmu(cmu_symbols, include_extras=include_extras, strict=strict)
263
+
264
+
265
+ # --- Tokenization & Normalization ---
266
+
267
+
268
+ def tokenize(ipa_string: str) -> list[str]:
269
+ """Parse IPA string into list of segment tokens."""
270
+ return _get_ipa().tokenize_ipa(ipa_string)
271
+
272
+
273
+ def segment(ipa_string: str) -> str:
274
+ """Parse IPA string and return whitespace-separated segments."""
275
+ return _get_ipa().segment_ipa(ipa_string)
276
+
277
+
278
+ def normalize(segments: str) -> str:
279
+ """Normalize whitespace-separated IPA segments into decodable IPA string."""
280
+ return _get_ipa().normalize_ipa(segments)
281
+
282
+
283
+ def normalize_lookalikes(text: str) -> str:
284
+ """Replace lookalike characters with proper IPA equivalents.
285
+
286
+ Converts visually similar keyboard characters to their
287
+ correct IPA Unicode codepoints (e.g., 'g' → 'ɡ', ':' → 'ː').
288
+ """
289
+ return _get_ipa().normalize_lookalikes(text)
290
+
291
+
292
+ def add_ties(segment: str) -> str:
293
+ """Add tie bars between base phones in a multi-phone segment."""
294
+ return _get_ipa().add_tie_bars(segment)
295
+
296
+
297
+ def feature_bundles(
298
+ ipa_string: str, with_defaults: bool = True
299
+ ) -> list[dict[str, str]]:
300
+ """Get list of feature dicts for each segment in an IPA string."""
301
+ return _get_ipa().compose(ipa_string, with_defaults=with_defaults)
302
+
303
+
304
+ def phones_matching(
305
+ query: dict[str, str] | list[str] | set[str], with_defaults: bool = True
306
+ ) -> list[str]:
307
+ """Get all phones matching features. Accepts dict or list/set of short names."""
308
+ return _get_ipa().phones_matching(query, with_defaults=with_defaults)
309
+
310
+
311
+ def features_to_shorts(bundle: dict[str, str]) -> list[str]:
312
+ """Convert a feature dict to list of short names."""
313
+ return _get_ipa().features_to_shorts(bundle)
314
+
315
+
316
+ def shorts_to_features(shorts: list[str] | set[str]) -> dict[str, str]:
317
+ """Convert list of short names to feature dict."""
318
+ return _get_ipa().shorts_to_features(shorts)
319
+
320
+
321
+ def _make_wiki_url(ipa: IPAFeatures, href: str | None) -> str | None:
322
+ """Construct full Wikipedia URL from article name."""
323
+ if href and ipa.wiki_base and not href.startswith("http"):
324
+ return ipa.wiki_base + href
325
+ return href
326
+
327
+
328
+ def wiki(phone: str) -> str | None:
329
+ """Get Wikipedia URL for an IPA phone symbol.
330
+
331
+ Example:
332
+ >>> ipakit.wiki("p")
333
+ 'https://en.wikipedia.org/wiki/Voiceless_bilabial_plosive'
334
+ """
335
+ ipa = _get_ipa()
336
+ href = None
337
+ if phone in ipa.phones:
338
+ href = ipa.phones[phone].features.get("href")
339
+ elif phone in ipa.diacritics:
340
+ href = ipa.diacritics[phone].features.get("href")
341
+ return _make_wiki_url(ipa, href)
342
+
343
+
344
+ def wiki_ref(name: str) -> str | None:
345
+ """Get Wikipedia URL for a general IPA reference.
346
+
347
+ Example:
348
+ >>> ipakit.wiki_ref("IPA")
349
+ 'https://en.wikipedia.org/wiki/International_Phonetic_Alphabet'
350
+ >>> ipakit.wiki_ref("X-SAMPA")
351
+ 'https://en.wikipedia.org/wiki/X-SAMPA'
352
+ """
353
+ ipa = _get_ipa()
354
+ href = ipa.references.get(name)
355
+ return _make_wiki_url(ipa, href)
356
+
357
+
358
+ def wiki_refs() -> dict[str, str]:
359
+ """Get all general IPA reference URLs.
360
+
361
+ Returns dict mapping reference names to full Wikipedia URLs.
362
+ """
363
+ ipa = _get_ipa()
364
+ return {
365
+ name: url
366
+ for name, href in ipa.references.items()
367
+ if (url := _make_wiki_url(ipa, href)) is not None
368
+ }
369
+
370
+
371
+ # --- Analysis functions ---
372
+
373
+
374
+ def describe(phone: str, with_defaults: bool = True) -> str:
375
+ """Generate human-readable IPA description for a phone.
376
+
377
+ Examples:
378
+ >>> ipakit.describe("p")
379
+ 'voiceless bilabial plosive'
380
+ >>> ipakit.describe("ɛ")
381
+ 'open-mid front unrounded vowel'
382
+ >>> ipakit.describe("t͡ʃ")
383
+ 'voiceless postalveolar affricate'
384
+ """
385
+ return _get_ipa().describe(phone, with_defaults=with_defaults)
386
+
387
+
388
+ def natural_class(
389
+ phones: list[str],
390
+ with_defaults: bool = True,
391
+ exclude_features: set[str] | None = None,
392
+ ) -> dict[str, str]:
393
+ """Find features shared by all phones in a set (natural class).
394
+
395
+ Examples:
396
+ >>> ipakit.natural_class(["p", "t", "k"]) # shared features (incl. defaults)
397
+ {'manner': 'plosive', ...'voiced': '-', ...}
398
+ >>> ipakit.natural_class(["i", "e", "ɛ"])
399
+ {'manner': 'vowel', ...'backness': 'front', ...}
400
+ """
401
+ return _get_ipa().natural_class(
402
+ phones, with_defaults=with_defaults, exclude_features=exclude_features
403
+ )
404
+
405
+
406
+ def minimal_pairs(
407
+ phone: str,
408
+ with_defaults: bool = True,
409
+ max_distance: float = 0.3,
410
+ ) -> list[tuple[str, str, str | None]]:
411
+ """Find phones that differ by approximately one feature (minimal pairs).
412
+
413
+ Returns list of (phone, differing_feature, differing_value) tuples.
414
+
415
+ Examples:
416
+ >>> ipakit.minimal_pairs("p")
417
+ [('ɸ', 'manner', 'fricative'), ('f', 'manner', 'fricative'), ...]
418
+ """
419
+ return _get_ipa().minimal_pairs(
420
+ phone, with_defaults=with_defaults, max_distance=max_distance
421
+ )
422
+
423
+
424
+ def nearest_phones(
425
+ phone: str,
426
+ n: int = 10,
427
+ with_defaults: bool = True,
428
+ ) -> list[tuple[str, float]]:
429
+ """Find the n nearest phones by phonetic distance.
430
+
431
+ Returns list of (phone, distance) tuples sorted by distance.
432
+
433
+ Examples:
434
+ >>> [(p, round(d, 3)) for p, d in ipakit.nearest_phones("p", n=3)]
435
+ [('ɸ', 0.005), ('f', 0.008), ('p͡f', 0.008)]
436
+ """
437
+ return _get_ipa().nearest_phones(phone, n=n, with_defaults=with_defaults)
438
+
439
+
440
+ def validate_ipa(ipa: str, strict: bool = False) -> list[dict[str, str]]:
441
+ """Validate an IPA string for well-formedness.
442
+
443
+ Returns a list of issue dicts. Empty list means valid.
444
+
445
+ Examples:
446
+ >>> ipakit.validate_ipa("kæt")
447
+ []
448
+ >>> ipakit.validate_ipa("k4t") # 'x', 'y', 'z' are all valid IPA; '4' is not
449
+ [{'type': 'error', 'code': 'unknown_symbol', 'message': "Unknown symbol '4' (U+0034)", 'position': '1', 'symbol': '4'}]
450
+ """
451
+ return _get_ipa().validate_ipa(ipa, strict=strict)
452
+
453
+
454
+ def is_valid_ipa(ipa: str) -> bool:
455
+ """Check if an IPA string is valid (no errors).
456
+
457
+ Examples:
458
+ >>> ipakit.is_valid_ipa("kæt")
459
+ True
460
+ >>> ipakit.is_valid_ipa("k4t") # 'x', 'y', 'z' are valid IPA; '4' is not
461
+ False
462
+ """
463
+ return _get_ipa().is_valid_ipa(ipa)
464
+
465
+
466
+ __all__ = [
467
+ # Classes
468
+ "CMUMapper",
469
+ "DistanceModel",
470
+ "Feature",
471
+ "IPAFeatures",
472
+ "Phone",
473
+ "PhoneMapping",
474
+ "Phoneset",
475
+ "WordDistanceResult",
476
+ # Constants
477
+ "DATA_DIR",
478
+ "DEFAULT_CMU_MAP",
479
+ "DEFAULT_IPA_FEATS",
480
+ "PHONEMAPS_DIR",
481
+ "TIE_BAR",
482
+ # Functions
483
+ "add_ties",
484
+ "confusability",
485
+ "describe",
486
+ "distance",
487
+ "distance_model",
488
+ "feature_bundles",
489
+ "features",
490
+ "features_from_cmu",
491
+ "features_from_xsampa",
492
+ "features_to_shorts",
493
+ "from_cmu",
494
+ "from_kirshenbaum",
495
+ "from_timit",
496
+ "ipa_to_phonemap",
497
+ "ipa_to_xsampa",
498
+ "is_valid_ipa",
499
+ "load_ipa_features",
500
+ "minimal_pairs",
501
+ "natural_class",
502
+ "nearest_phones",
503
+ "normalize",
504
+ "normalize_lookalikes",
505
+ "normalized_distance",
506
+ "phonemap_to_ipa",
507
+ "phones_matching",
508
+ "segment",
509
+ "shorts_to_features",
510
+ "to_cmu",
511
+ "to_ipa",
512
+ "to_kirshenbaum",
513
+ "to_timit",
514
+ "tokenize",
515
+ "validate_ipa",
516
+ "wiki",
517
+ "wiki_ref",
518
+ "wiki_refs",
519
+ "word_distance",
520
+ "word_similarity",
521
+ "xsampa_to_ipa",
522
+ ]
ipakit/_base.py ADDED
@@ -0,0 +1,65 @@
1
+ """Shared base for IPAFeatures mixins.
2
+
3
+ The mixins (Analysis/Distance/Hierarchy/Validation) call attributes and methods
4
+ that live on the concrete ``IPAFeatures`` class or on sibling mixins. Inheriting
5
+ this base lets a type checker resolve those references without each mixin
6
+ annotating ``self`` as ``IPAFeatures`` (which strict mypy rejects, since the
7
+ erased self type must be a supertype of the defining class).
8
+
9
+ At runtime these declarations are inert: ``IPAFeatures`` and the mixins override
10
+ every member below, so the stub bodies are never executed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .models import Feature, Phone, Phoneset
16
+
17
+
18
+ class IPAFeaturesBase:
19
+ """Declares the cross-mixin surface of ``IPAFeatures`` for type checking."""
20
+
21
+ # Data populated by IPAFeatures._load()
22
+ phones: dict[str, Phone]
23
+ diacritics: dict[str, Phone]
24
+ separators: dict[str, Phone]
25
+ features: dict[str, Feature]
26
+
27
+ @property
28
+ def feature_order(self) -> list[str]:
29
+ raise NotImplementedError
30
+
31
+ @property
32
+ def consonant_manners(self) -> frozenset[str]:
33
+ raise NotImplementedError
34
+
35
+ @property
36
+ def stress_markers(self) -> dict[str, int]:
37
+ raise NotImplementedError
38
+
39
+ @property
40
+ def stress_to_marker(self) -> dict[int, str]:
41
+ raise NotImplementedError
42
+
43
+ @property
44
+ def syllable_break(self) -> str:
45
+ raise NotImplementedError
46
+
47
+ def get_features(self, phone: str, with_defaults: bool = True) -> dict[str, str]:
48
+ raise NotImplementedError
49
+
50
+ def expand_ligatures(self, ipa: str) -> str:
51
+ raise NotImplementedError
52
+
53
+ def compose(
54
+ self,
55
+ segment: str,
56
+ with_defaults: bool = True,
57
+ phoneset: Phoneset | None = None,
58
+ ) -> list[dict[str, str]]:
59
+ raise NotImplementedError
60
+
61
+ def tokenize_ipa(self, ipa: str, phoneset: Phoneset | None = None) -> list[str]:
62
+ raise NotImplementedError
63
+
64
+ def distance(self, phone1: str, phone2: str) -> float:
65
+ raise NotImplementedError
ipakit/_convert.py ADDED
@@ -0,0 +1,95 @@
1
+ """Shared tokenization/conversion helpers.
2
+
3
+ Several converters (feature parsing, stress normalization, IPA validation, CMU,
4
+ X-SAMPA and phonemap conversion) all walk a string left to right, taking the
5
+ longest substring that is a key in some lookup. That one loop is ``longest_match``.
6
+
7
+ They also share an opt-in ``strict`` error policy: collect the symbols that could
8
+ not be converted and, when strict, raise via ``require_convertible``.
9
+
10
+ Per-site state (diacritic collection, stress handling, validation tracking)
11
+ stays in the caller; only these shared steps live here.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Collection, Mapping
17
+
18
+ from .constants import TIE_BAR
19
+
20
+
21
+ def require_convertible(skipped: list[str], what: str) -> None:
22
+ """Raise ``ValueError`` if any input symbols could not be converted.
23
+
24
+ Used by converters called with ``strict=True``. ``what`` names the
25
+ conversion, e.g. ``"to CMU ARPABET"`` or ``"IPA -> X-SAMPA"``.
26
+ """
27
+ if skipped:
28
+ unknown = sorted(set(skipped))
29
+ raise ValueError(f"Cannot convert {what}: unknown symbols {unknown}")
30
+
31
+
32
+ def longest_match(
33
+ text: str,
34
+ start: int,
35
+ lookup: Collection[str],
36
+ max_len: int,
37
+ tie_set: Collection[str] | None = None,
38
+ ) -> tuple[str | None, int]:
39
+ """Find the longest ``text[start:]`` prefix (up to ``max_len``) in ``lookup``.
40
+
41
+ Returns ``(matched_substring, length)``, or ``(None, 0)`` if nothing matches.
42
+ The caller maps the substring to a value (``lookup[match]``) when needed.
43
+
44
+ If ``tie_set`` is given, a candidate containing the tie bar also matches when
45
+ every tie-bar-separated part is a non-empty member of ``tie_set`` (handles
46
+ composed phones like ``t͡ʃ`` that are not themselves keys). A lone or dangling
47
+ tie bar -- which produces an empty part -- is therefore not a match, so the
48
+ caller can flag it. ``max_len`` must be wide enough to span such composites,
49
+ so it is a deliberate bound, not the longest key length.
50
+ """
51
+ for length in range(min(max_len, len(text) - start), 0, -1):
52
+ candidate = text[start : start + length]
53
+ if candidate in lookup:
54
+ return candidate, length
55
+ if tie_set is not None and TIE_BAR in candidate:
56
+ parts = candidate.split(TIE_BAR)
57
+ if all(p in tie_set for p in parts):
58
+ return candidate, length
59
+ return None, 0
60
+
61
+
62
+ def convert_greedy(
63
+ text: str,
64
+ lookup: Mapping[str, str],
65
+ *,
66
+ max_len: int | None = None,
67
+ strict: bool = False,
68
+ what: str = "",
69
+ ) -> list[str]:
70
+ """Greedy longest-match conversion of ``text`` through a string->string map.
71
+
72
+ Walks left to right, replacing the longest matching key with its value;
73
+ unmatched characters are skipped. With ``strict=True`` the skipped symbols
74
+ raise ``ValueError`` via ``require_convertible`` (``what`` names the
75
+ direction). ``max_len`` defaults to the longest key length.
76
+ """
77
+ if not lookup:
78
+ return []
79
+ if max_len is None:
80
+ max_len = max(len(k) for k in lookup)
81
+ out: list[str] = []
82
+ skipped: list[str] = []
83
+ i = 0
84
+ n = len(text)
85
+ while i < n:
86
+ key, length = longest_match(text, i, lookup, max_len)
87
+ if key is not None:
88
+ out.append(lookup[key])
89
+ i += length
90
+ else:
91
+ skipped.append(text[i])
92
+ i += 1
93
+ if strict:
94
+ require_convertible(skipped, what)
95
+ return out