From bee4ec0b047966db88064d91c8d56d929370f0a4 Mon Sep 17 00:00:00 2001 From: Shai Shmuel Date: Mon, 19 Oct 2026 14:52:59 +0300 Subject: [PATCH 1/2] fix(models): undo Moses entity escaping in MADLAD-400 output For some target languages MADLAD-400 returns text in the Moses tokenizer's escaped form, for example "d ' Android" instead of "d'Android" with <2fr_CA>. The CTranslate2 adapter passed it through. Add an unescape_moses_entities load-time option to the CTranslate2 generation adapter and enable it for google/madlad400-3b-mt. It unescapes the Moses entity set in the decoded output, skips entities that occur literally in the input, and repairs spacing only for French elision and balanced quote pairs. Co-Authored-By: Claude Opus 5.5 --- .../models/google__madlad400-3b-mt.yaml | 1 + .../adapters/ctranslate2/_moses_entities.py | 95 ++++++++++++++++++ .../adapters/ctranslate2/generation.py | 11 +++ .../adapters/test_ctranslate2_generation.py | 68 ++++++++++++- .../test_ctranslate2_moses_entities.py | 99 +++++++++++++++++++ 5 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 packages/sie_server/src/sie_server/adapters/ctranslate2/_moses_entities.py create mode 100644 packages/sie_server/tests/adapters/test_ctranslate2_moses_entities.py diff --git a/packages/sie_server/models/google__madlad400-3b-mt.yaml b/packages/sie_server/models/google__madlad400-3b-mt.yaml index dc8f0cf86..fda799e93 100644 --- a/packages/sie_server/models/google__madlad400-3b-mt.yaml +++ b/packages/sie_server/models/google__madlad400-3b-mt.yaml @@ -36,6 +36,7 @@ profiles: compute_type: bfloat16 native_batch_max_size: 8 native_batch_max_tokens: 4096 + unescape_moses_entities: true runtime: first_chunk_timeout_s: 180 inter_chunk_timeout_s: 10 diff --git a/packages/sie_server/src/sie_server/adapters/ctranslate2/_moses_entities.py b/packages/sie_server/src/sie_server/adapters/ctranslate2/_moses_entities.py new file mode 100644 index 000000000..9edbbceb9 --- /dev/null +++ b/packages/sie_server/src/sie_server/adapters/ctranslate2/_moses_entities.py @@ -0,0 +1,95 @@ +"""Undo the Moses tokenizer's entity escaping in translation output. + +The Moses tokenizer escapes ``& < > ' " [ ] |`` as entities and splits them +off the surrounding words with spaces. MADLAD-400 returns text in that form +for some target languages, so a French translation can come back as +``d ' Android`` instead of ``d'Android``. + +The repair is deliberately conservative: + +* An entity is only unescaped when the input text does not contain it + literally. A request that really translates ``&`` keeps it. +* Spacing is only repaired where the form is unambiguous: French elision + (``l ' offre`` becomes ``l'offre``) and balanced, space-delimited + ``"`` pairs (``" Bonjour "`` becomes ``"Bonjour"``). + Everything else is unescaped in place and keeps its spaces, so + ``M & M`` becomes ``M & M``. +""" + +from __future__ import annotations + +import re + +_ENTITIES = { + "&": "&", + "<": "<", + ">": ">", + "'": "'", + """: '"', + "[": "[", + "]": "]", + "|": "|", +} +# One pass over the text, so an escaped entity such as ``&lt;`` is +# unescaped once, to ``<``, and not again to ``<``. +_ENTITY = re.compile("|".join(re.escape(entity) for entity in _ENTITIES)) + +_APOS = "'" +_QUOT = """ + +# French elided forms followed by an escaped apostrophe and the next word: +# c', d', j', l', m', n', s', t', the qu' family (qu', jusqu', lorsqu', +# quelqu'...) and aujourd'hui. Only the elided word and the word after it +# decide the join, so a single-quoted span such as ``dit ' oui '`` +# is left spaced. +_ELISION = re.compile( + r"(? str: + """Return ``text`` with Moses entities unescaped and their spacing repaired. + + Args: + text: Decoded model output. + source: The input text the output was generated from. Entities that + occur literally in it are kept as they are. + """ + escaped = frozenset(entity for entity in _ENTITIES if entity in text and entity not in source) + if not escaped: + return text + if _APOS in escaped: + text = _ELISION.sub(r"\1'", text) + if _QUOT in escaped: + text = _attach_quote_pairs(text) + return _ENTITY.sub(lambda match: _ENTITIES[match[0]] if match[0] in escaped else match[0], text) + + +def _attach_quote_pairs(text: str) -> str: + """Attach balanced ``"`` pairs to the words they enclose. + + The tokenizer turns ``"word"`` into ``" word "``. The output is + only respaced when every escaped quote can be paired in order, each pair + encloses text with exactly one space on either side, an opening quote does + not follow a word character directly and a closing quote does not precede + one. A literal ``"`` elsewhere in the output, an odd count or any other + shape leaves the spacing as it is. + """ + if '"' in text: + return text + parts = text.split(_QUOT) + if len(parts) % 2 == 0: + return text + for index in range(1, len(parts), 2): + before, inner, after = parts[index - 1], parts[index], parts[index + 1] + if (before or index > 1) and not before[-1:].isspace(): + return text + if len(inner) < 3 or inner[0] != " " or inner[-1] != " " or inner[1].isspace() or inner[-2].isspace(): + return text + if after and (after[0].isalnum() or after[0] == "_"): + return text + pieces = [parts[0]] + for index in range(1, len(parts), 2): + pieces.extend(('"', parts[index][1:-1], '"', parts[index + 1])) + return "".join(pieces) diff --git a/packages/sie_server/src/sie_server/adapters/ctranslate2/generation.py b/packages/sie_server/src/sie_server/adapters/ctranslate2/generation.py index 813a2e675..791333395 100644 --- a/packages/sie_server/src/sie_server/adapters/ctranslate2/generation.py +++ b/packages/sie_server/src/sie_server/adapters/ctranslate2/generation.py @@ -28,6 +28,7 @@ ) from sie_server.adapters._spec import AdapterSpec from sie_server.adapters._types import ERR_NOT_LOADED, ComputePrecision +from sie_server.adapters.ctranslate2._moses_entities import unescape_moses_entities from sie_server.types.grammar import GrammarSpec from sie_server.types.inputs import ImageInput, VideoInput @@ -109,6 +110,7 @@ class _DecodeOptions: @dataclass(frozen=True, slots=True) class _PreparedGeneration: + prompt: str source_tokens: tuple[str, ...] prompt_tokens: int options: _DecodeOptions @@ -171,6 +173,7 @@ def __init__( native_batch_max_tokens: int = 8192, native_batch_wait_ms: float = 2.0, max_pending_requests: int = 64, + unescape_moses_entities: bool = False, ) -> None: artifact = Path(artifact_path) if not str(artifact_path): @@ -186,6 +189,11 @@ def __init__( self._native_batch_max_tokens = _positive_int("native_batch_max_tokens", native_batch_max_tokens) self._native_batch_wait_s = _non_negative_float("native_batch_wait_ms", native_batch_wait_ms) / 1000 self._max_pending_requests = _positive_int("max_pending_requests", max_pending_requests) + if not isinstance(unescape_moses_entities, bool): + raise ValueError("unescape_moses_entities must be a boolean") + # For models that emit Moses-escaped entities such as ``'`` in their + # output; see ``_moses_entities``. + self._unescape_moses_entities = unescape_moses_entities self._translator: Any | None = None self._tokenizer: Any | None = None @@ -433,6 +441,7 @@ def _prepare_generation(self, parameters: Mapping[str, Any]) -> _PreparedGenerat f"prompt token count {prompt_tokens} exceeds native_batch_max_tokens={self._native_batch_max_tokens}" ) return _PreparedGeneration( + prompt=prompt, source_tokens=source_tokens, prompt_tokens=prompt_tokens, options=_DecodeOptions( @@ -606,6 +615,8 @@ def callback(step: Any) -> bool: ended = bool(result_tokens and result_tokens[-1] in end_tokens) output_tokens = result_tokens[:-1] if ended else result_tokens text = self._decode_tokens(output_tokens) + if self._unescape_moses_entities: + text = unescape_moses_entities(text, request.prepared.prompt) completion_tokens = len(output_tokens) finish_reason: Literal["stop", "length"] = "stop" if ended else "length" request.loop.call_soon_threadsafe(request.complete, text, completion_tokens, finish_reason) diff --git a/packages/sie_server/tests/adapters/test_ctranslate2_generation.py b/packages/sie_server/tests/adapters/test_ctranslate2_generation.py index 24a8aacd1..1d5d598f3 100644 --- a/packages/sie_server/tests/adapters/test_ctranslate2_generation.py +++ b/packages/sie_server/tests/adapters/test_ctranslate2_generation.py @@ -23,7 +23,22 @@ def __init__(self, *, clean_up_tokenization_spaces: bool = False) -> None: self.encode_calls = 0 self.decode_cleanup_calls: list[bool | None] = [] self.clean_up_tokenization_spaces = clean_up_tokenization_spaces - self._tokens = ["", "", "hello", "world", "bonjour", " monde", " !", "!"] + self._tokens = [ + "", + "", + "hello", + "world", + "bonjour", + " monde", + " !", + "!", + "&", + "l", + " '", + " ami", + " &", + " co", + ] self.eos_token_id = 1 def encode(self, text: str, *, add_special_tokens: bool) -> list[int]: @@ -59,8 +74,15 @@ class _FakeResult: class _FakeTranslator: - def __init__(self, *, block: threading.Event | None = None, append_end_token: bool = False) -> None: + def __init__( + self, + *, + block: threading.Event | None = None, + append_end_token: bool = False, + outputs: list[str] | None = None, + ) -> None: self.calls: list[dict[str, Any]] = [] + self.outputs = outputs or ["bonjour", " monde"] self.block = block self.append_end_token = append_end_token self.started = threading.Event() @@ -73,7 +95,7 @@ def translate_batch(self, source: list[list[str]], **kwargs: Any) -> list[_FakeR if self.block is not None: self.block.wait(timeout=2) max_length = kwargs["max_decoding_length"] - outputs = ["bonjour", " monde"][:max_length] + outputs = self.outputs[:max_length] callback = kwargs["callback"] for batch_id in range(len(source)): for token in outputs: @@ -117,6 +139,7 @@ def _adapter( native_batch_max_tokens: int = 64, native_batch_wait_ms: float = 5, max_pending_requests: int = 64, + unescape_moses_entities: bool = False, ) -> tuple[CTranslate2GenerationAdapter, _FakeTranslator, _FakeCTranslate2, _FakeTokenizer]: fake_translator = translator or _FakeTranslator() fake_module = _FakeCTranslate2(fake_translator) @@ -129,6 +152,7 @@ def _adapter( native_batch_max_tokens=native_batch_max_tokens, native_batch_wait_ms=native_batch_wait_ms, max_pending_requests=max_pending_requests, + unescape_moses_entities=unescape_moses_entities, ) adapter._translator = fake_translator adapter._tokenizer = tokenizer @@ -332,6 +356,44 @@ def test_natural_end_token_at_max_length_reports_stop_without_counting_or_decodi assert chunks[-1].completion_tokens == 2 +_ESCAPED_OUTPUT = ["l", " '", " ami", " &", " co"] + + +@pytest.mark.parametrize( + ("unescape_moses_entities", "prompt", "expected"), + [ + (False, "hello world", "l ' ami & co"), + (True, "hello world", "l'ami & co"), + (True, "hello & world", "l'ami & co"), + ], +) +def test_output_unescapes_moses_entities_only_when_enabled( + tmp_path: Path, + unescape_moses_entities: bool, + prompt: str, + expected: str, +) -> None: + adapter, _, _, _ = _adapter( + tmp_path, + translator=_FakeTranslator(outputs=_ESCAPED_OUTPUT), + unescape_moses_entities=unescape_moses_entities, + ) + + chunks = asyncio.run(_collect(adapter.generate(prompt, max_new_tokens=8, seed=0))) + + assert "".join(chunk.text_delta for chunk in chunks) == expected + assert chunks[-1].completion_tokens == len(_ESCAPED_OUTPUT) + + +def test_unescape_moses_entities_option_must_be_a_boolean(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="unescape_moses_entities must be a boolean"): + CTranslate2GenerationAdapter( + artifact_path=tmp_path, + ct2_compute_type="float16", + unescape_moses_entities="yes", # type: ignore[arg-type] + ) + + def test_scheduler_batches_only_exact_compatible_requests(tmp_path: Path) -> None: async def run() -> tuple[list[Any], list[Any], list[Any]]: adapter, translator, _, _ = _adapter(tmp_path, native_batch_wait_ms=20) diff --git a/packages/sie_server/tests/adapters/test_ctranslate2_moses_entities.py b/packages/sie_server/tests/adapters/test_ctranslate2_moses_entities.py new file mode 100644 index 000000000..18ab11342 --- /dev/null +++ b/packages/sie_server/tests/adapters/test_ctranslate2_moses_entities.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import pytest +from sie_server.adapters.ctranslate2._moses_entities import unescape_moses_entities + +_SOURCE = "<2fr> A plain source sentence." + + +@pytest.mark.parametrize( + ("escaped", "expected"), + [ + ("C ' est l ' heure d ' aller à l ' école.", "C'est l'heure d'aller à l'école."), + ("Je n ' ai pas dit qu ' Anne s ' en va.", "Je n'ai pas dit qu'Anne s'en va."), + ("Il m ' a dit qu ' il t ' attend.", "Il m'a dit qu'il t'attend."), + ("J ' arrive jusqu ' ici.", "J'arrive jusqu'ici."), + ("Lorsqu ' il pleut, quelqu ' un reste.", "Lorsqu'il pleut, quelqu'un reste."), + ("Aujourd ' hui, l ' été commence.", "Aujourd'hui, l'été commence."), + ("L ' Hôtel ouvre et D ' ailleurs ferme.", "L'Hôtel ouvre et D'ailleurs ferme."), + ], +) +def test_french_elision_rejoins_the_apostrophe(escaped: str, expected: str) -> None: + assert unescape_moses_entities(escaped, _SOURCE) == expected + + +@pytest.mark.parametrize( + ("escaped", "expected"), + [ + # Not an elided form: unescaped, spacing kept. + ("Il dit ' oui ' .", "Il dit ' oui ' ."), + # Elided form followed by a digit rather than a word. + ("l ' 2", "l ' 2"), + # Elided letter inside a longer word. + ("le mot ' x", "le mot ' x"), + ], +) +def test_other_apostrophes_are_unescaped_without_respacing(escaped: str, expected: str) -> None: + assert unescape_moses_entities(escaped, _SOURCE) == expected + + +@pytest.mark.parametrize( + ("escaped", "expected"), + [ + ("Il a dit " bonjour " et il est parti.", 'Il a dit "bonjour" et il est parti.'), + ("" Oui, tout de suite ", dit-elle.", '"Oui, tout de suite", dit-elle.'), + ("" Un " puis " deux ".", '"Un" puis "deux".'), + ("Le projet " Nord " avance.", 'Le projet "Nord" avance.'), + ], +) +def test_balanced_quote_pairs_attach_to_the_quoted_text(escaped: str, expected: str) -> None: + assert unescape_moses_entities(escaped, _SOURCE) == expected + + +@pytest.mark.parametrize( + ("escaped", "expected"), + [ + # Odd count: which quote opens is unknown. + ("Fin du texte. "", 'Fin du texte. "'), + ("" a " b "", '" a " b "'), + # A literal quote elsewhere makes the pairing ambiguous. + ('"Oui" et " non "', '"Oui" et " non "'), + # A quote that touches a word is not a standalone token. + ("mot" x "", 'mot" x "'), + ("" x "mot", '" x "mot'), + # Not exactly one space inside the pair. + ("" x "", '" x "'), + ("" "", '" "'), + ], +) +def test_ambiguous_quotes_are_unescaped_without_respacing(escaped: str, expected: str) -> None: + assert unescape_moses_entities(escaped, _SOURCE) == expected + + +def test_remaining_moses_entities_are_unescaped_in_place() -> None: + escaped = "R & D, a < b > c, [ d ] et e | f" + + assert unescape_moses_entities(escaped, _SOURCE) == "R & D, a < b > c, [ d ] et e | f" + + +def test_unescaping_is_a_single_pass() -> None: + assert unescape_moses_entities("Écrivez &lt; et &apos;", _SOURCE) == "Écrivez < et '" + + +def test_entities_present_in_the_source_are_kept() -> None: + source = "<2fr> Tom & Jerry" + + assert unescape_moses_entities("Tom & Jerry", source) == "Tom & Jerry" + assert unescape_moses_entities("l ' ami de Tom & Jerry", source) == "l'ami de Tom & Jerry" + + +def test_apostrophe_present_in_the_source_keeps_its_spacing() -> None: + source = "<2fr> Say ' hello" + + assert unescape_moses_entities("Dis l ' ami", source) == "Dis l ' ami" + + +def test_text_without_entities_is_returned_unchanged() -> None: + text = "Une phrase avec ' des \" signes & et < > [ ] |." + + assert unescape_moses_entities(text, _SOURCE) is text From 4488cc041e300d67014cdedf234803aba6d5b7cc Mon Sep 17 00:00:00 2001 From: Shai Shmuel Date: Mon, 19 Oct 2026 15:56:17 +0300 Subject: [PATCH 2/2] fix(ctranslate2): rejoin French elision only for a French target Review follow-up: - French elision is rejoined only when the prompt carries a French target tag (<2fr>, <2fr_CA>), so "Rock ' n ' Roll" in another language is unescaped but keeps its spaces. - A quoted elided letter is joined like an elision; a test pins that. - The adapter imports the helper under a private name, so the option of the same name no longer shadows it. - Docstrings state the quote-pairing rule as the code applies it and use a made-up example. Co-Authored-By: Claude Opus 5.5 --- .../adapters/ctranslate2/_moses_entities.py | 26 ++++++++++++------- .../adapters/ctranslate2/generation.py | 4 +-- .../adapters/test_ctranslate2_generation.py | 10 ++++--- .../test_ctranslate2_moses_entities.py | 15 +++++++++++ 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/sie_server/src/sie_server/adapters/ctranslate2/_moses_entities.py b/packages/sie_server/src/sie_server/adapters/ctranslate2/_moses_entities.py index 9edbbceb9..e0a89e70e 100644 --- a/packages/sie_server/src/sie_server/adapters/ctranslate2/_moses_entities.py +++ b/packages/sie_server/src/sie_server/adapters/ctranslate2/_moses_entities.py @@ -9,11 +9,12 @@ * An entity is only unescaped when the input text does not contain it literally. A request that really translates ``&`` keeps it. -* Spacing is only repaired where the form is unambiguous: French elision - (``l ' offre`` becomes ``l'offre``) and balanced, space-delimited +* Spacing is only repaired where the form is unambiguous: French elision, + when the prompt targets French (``<2fr>``, ``<2fr_CA>``), so + ``l ' offre`` becomes ``l'offre``; and balanced, space-delimited ``"`` pairs (``" Bonjour "`` becomes ``"Bonjour"``). Everything else is unescaped in place and keeps its spaces, so - ``M & M`` becomes ``M & M``. + ``R & D`` becomes ``R & D``. """ from __future__ import annotations @@ -41,11 +42,15 @@ # c', d', j', l', m', n', s', t', the qu' family (qu', jusqu', lorsqu', # quelqu'...) and aujourd'hui. Only the elided word and the word after it # decide the join, so a single-quoted span such as ``dit ' oui '`` -# is left spaced. +# is left spaced, while a quoted single letter that is itself an elided form +# (``' l ' est``) is joined to the word after it. The join only runs +# when the prompt targets French. _ELISION = re.compile( r"(?") def unescape_moses_entities(text: str, source: str) -> str: @@ -53,13 +58,14 @@ def unescape_moses_entities(text: str, source: str) -> str: Args: text: Decoded model output. - source: The input text the output was generated from. Entities that - occur literally in it are kept as they are. + source: The input text the output was generated from, target tag + included. Entities that occur literally in it are kept as they + are, and French elision is only repaired for a French target tag. """ escaped = frozenset(entity for entity in _ENTITIES if entity in text and entity not in source) if not escaped: return text - if _APOS in escaped: + if _APOS in escaped and _FRENCH_TARGET.match(source): text = _ELISION.sub(r"\1'", text) if _QUOT in escaped: text = _attach_quote_pairs(text) @@ -71,9 +77,9 @@ def _attach_quote_pairs(text: str) -> str: The tokenizer turns ``"word"`` into ``" word "``. The output is only respaced when every escaped quote can be paired in order, each pair - encloses text with exactly one space on either side, an opening quote does - not follow a word character directly and a closing quote does not precede - one. A literal ``"`` elsewhere in the output, an odd count or any other + encloses text with exactly one space on either side, an opening quote + starts the text or follows whitespace and a closing quote does not precede + a word character. A literal ``"`` elsewhere in the output, an odd count or any other shape leaves the spacing as it is. """ if '"' in text: diff --git a/packages/sie_server/src/sie_server/adapters/ctranslate2/generation.py b/packages/sie_server/src/sie_server/adapters/ctranslate2/generation.py index 791333395..1e4fd6ea3 100644 --- a/packages/sie_server/src/sie_server/adapters/ctranslate2/generation.py +++ b/packages/sie_server/src/sie_server/adapters/ctranslate2/generation.py @@ -28,7 +28,7 @@ ) from sie_server.adapters._spec import AdapterSpec from sie_server.adapters._types import ERR_NOT_LOADED, ComputePrecision -from sie_server.adapters.ctranslate2._moses_entities import unescape_moses_entities +from sie_server.adapters.ctranslate2._moses_entities import unescape_moses_entities as _unescape_moses_entities from sie_server.types.grammar import GrammarSpec from sie_server.types.inputs import ImageInput, VideoInput @@ -616,7 +616,7 @@ def callback(step: Any) -> bool: output_tokens = result_tokens[:-1] if ended else result_tokens text = self._decode_tokens(output_tokens) if self._unescape_moses_entities: - text = unescape_moses_entities(text, request.prepared.prompt) + text = _unescape_moses_entities(text, request.prepared.prompt) completion_tokens = len(output_tokens) finish_reason: Literal["stop", "length"] = "stop" if ended else "length" request.loop.call_soon_threadsafe(request.complete, text, completion_tokens, finish_reason) diff --git a/packages/sie_server/tests/adapters/test_ctranslate2_generation.py b/packages/sie_server/tests/adapters/test_ctranslate2_generation.py index 1d5d598f3..3682662ea 100644 --- a/packages/sie_server/tests/adapters/test_ctranslate2_generation.py +++ b/packages/sie_server/tests/adapters/test_ctranslate2_generation.py @@ -38,6 +38,8 @@ def __init__(self, *, clean_up_tokenization_spaces: bool = False) -> None: " ami", " &", " co", + "<2fr>", + "<2en>", ] self.eos_token_id = 1 @@ -362,9 +364,11 @@ def test_natural_end_token_at_max_length_reports_stop_without_counting_or_decodi @pytest.mark.parametrize( ("unescape_moses_entities", "prompt", "expected"), [ - (False, "hello world", "l ' ami & co"), - (True, "hello world", "l'ami & co"), - (True, "hello & world", "l'ami & co"), + (False, "<2fr> hello world", "l ' ami & co"), + (True, "<2fr> hello world", "l'ami & co"), + (True, "<2fr> hello & world", "l'ami & co"), + # Elision is only rejoined for a French target tag. + (True, "<2en> hello world", "l ' ami & co"), ], ) def test_output_unescapes_moses_entities_only_when_enabled( diff --git a/packages/sie_server/tests/adapters/test_ctranslate2_moses_entities.py b/packages/sie_server/tests/adapters/test_ctranslate2_moses_entities.py index 18ab11342..96e9379e4 100644 --- a/packages/sie_server/tests/adapters/test_ctranslate2_moses_entities.py +++ b/packages/sie_server/tests/adapters/test_ctranslate2_moses_entities.py @@ -87,6 +87,21 @@ def test_entities_present_in_the_source_are_kept() -> None: assert unescape_moses_entities("l ' ami de Tom & Jerry", source) == "l'ami de Tom & Jerry" +def test_elision_is_only_repaired_for_a_french_target() -> None: + escaped = "Rock ' n ' Roll" + + assert unescape_moses_entities(escaped, "<2en> Rock and roll") == "Rock ' n ' Roll" + assert unescape_moses_entities("l ' ami", "<2fr_CA> the friend") == "l'ami" + + +def test_a_quoted_elided_letter_is_joined_like_an_elision() -> None: + # Pinned on purpose: a quoted single letter that is an elided form is + # indistinguishable from an elision, so it joins the word after it. + escaped = "la lettre ' l ' est muette" + + assert unescape_moses_entities(escaped, _SOURCE) == "la lettre ' l'est muette" + + def test_apostrophe_present_in_the_source_keeps_its_spacing() -> None: source = "<2fr> Say ' hello"