| """Conservative, context-aware vocabulary refinement (no hard-coded word lists)."""
|
|
|
| from __future__ import annotations
|
|
|
| import logging
|
| import re
|
| from dataclasses import dataclass, field
|
| from functools import lru_cache
|
| from typing import Any
|
|
|
| from lemminflect import getInflection
|
| from wordfreq import zipf_frequency
|
|
|
| from app.config import (
|
| ENGINE_LEXICAL_MAX_FREQUENCY_GAP,
|
| ENGINE_LEXICAL_MAX_HARDER_GAP,
|
| ENGINE_LEXICAL_MAX_CHANGES,
|
| ENGINE_LEXICAL_MAX_SIMPLER_GAP,
|
| ENGINE_LEXICAL_MIN_ZIPF,
|
| ENGINE_LEXICAL_PREFER_SIMPLER,
|
| ENGINE_WORDNET_LEXICON,
|
| )
|
| from app.engine.models import LexicalChange
|
| from app.engine.quality import substitution_pos_stable
|
| from app.pipeline.nlp import get_nlp
|
|
|
| _POS_MAP = {"NOUN": "n", "VERB": "v", "ADJ": "a", "ADV": "r"}
|
| _PROTECTED_MARKER = re.compile(r"ZZPROTECTED(?:URL|EMAIL|PATH)\d+ZZ", re.I)
|
| _WORD = re.compile(r"[A-Za-z][A-Za-z'-]*")
|
| _CITATION = re.compile(
|
| r"(?:\[[0-9,\s-]+\]|\([A-Z][^()]{0,60},\s*(?:19|20)\d{2}\))"
|
| )
|
| _QUOTES = frozenset({'"', "“", "”", "‘", "’"})
|
| _CLEFT_PREFIX = re.compile(r"^it is\b", re.I)
|
| logger = logging.getLogger("plainrewrite.lexical")
|
|
|
|
|
| @dataclass
|
| class LexicalResult:
|
| text: str
|
| changes: list[LexicalChange] = field(default_factory=list)
|
| confidence: float = 0.0
|
| reason: str = ""
|
|
|
|
|
| @lru_cache(maxsize=1)
|
| def _get_wordnet() -> Any | None:
|
| try:
|
| import wn
|
|
|
| wn.config.allow_multithreading = True
|
| return wn.Wordnet(ENGINE_WORDNET_LEXICON)
|
| except Exception as exc:
|
| logger.warning("Open English WordNet unavailable: %s", exc)
|
| return None
|
|
|
|
|
| def lexical_resource_available() -> bool:
|
| return _get_wordnet() is not None
|
|
|
|
|
| def _context_terms(doc, target) -> set[str]:
|
| terms: set[str] = set()
|
| for token in doc:
|
| if token.i == target.i or token.is_stop or not token.is_alpha:
|
| continue
|
| if token.pos_ not in _POS_MAP and token.pos_ != "PROPN":
|
| continue
|
| for value in (token.text.lower(), token.lemma_.lower()):
|
| if len(value) >= 3:
|
| terms.add(value)
|
| return terms
|
|
|
|
|
| def _terms_from_text(text: str, stop_words: set[str]) -> set[str]:
|
| terms = {match.group(0).lower() for match in _WORD.finditer(text or "")}
|
| return {term for term in terms if len(term) >= 3 and term not in stop_words}
|
|
|
|
|
| def _gloss_terms(synset, stop_words: set[str]) -> tuple[set[str], set[str]]:
|
| definition = _terms_from_text(synset.definition(), stop_words)
|
| examples: set[str] = set()
|
| try:
|
| for example in synset.examples():
|
| examples.update(_terms_from_text(example, stop_words))
|
| except Exception:
|
| pass
|
| return definition, examples
|
|
|
|
|
| def _sense_score(synset, context: set[str], stop_words: set[str]) -> float:
|
| if not context:
|
| return 0.0
|
| definition, examples = _gloss_terms(synset, stop_words)
|
| definition_overlap = context & definition
|
| if not definition_overlap:
|
| return 0.0
|
| example_overlap = context & examples
|
| weighted = len(definition_overlap) + (0.25 * len(example_overlap))
|
| return min(1.0, weighted / max(1, min(5, len(context))))
|
|
|
|
|
| def _eligible(token, doc) -> bool:
|
| if token.pos_ not in _POS_MAP:
|
| return False
|
| lemma = token.lemma_.lower()
|
| if (
|
| token.pos_ == "PROPN"
|
| or token.ent_type_
|
| or token.is_stop
|
| or not token.is_alpha
|
| or len(lemma) < 3
|
| or token.dep_ in {"aux", "auxpass", "neg", "mark"}
|
| or _PROTECTED_MARKER.search(token.text)
|
| ):
|
| return False
|
| if token.tag_ == "VBG" and token.dep_ in {"ROOT", "csubj", "nsubj", "attr"}:
|
| return False
|
| if token.tag_ == "VBG" and token.i <= 2 and _CLEFT_PREFIX.search(doc.text):
|
| return False
|
| if lemma in {"be", "have", "do"}:
|
| return False
|
| if _CLEFT_PREFIX.search(doc.text) and token.text.lower() in {"it"}:
|
| return False
|
|
|
| if token.i + 1 < len(doc) and doc[token.i + 1].lower_ == "to":
|
| return False
|
|
|
| if token.pos_ == "VERB" and (
|
| token.dep_ in {"auxpass"}
|
| or any(child.dep_ == "auxpass" for child in token.children)
|
| ):
|
| return False
|
| return True
|
|
|
|
|
| def _impact_rank(token) -> int:
|
| if token.pos_ == "ADV":
|
| return 0
|
| if token.pos_ == "ADJ":
|
| return 1
|
| if token.pos_ == "VERB" and token.dep_ != "ROOT":
|
| return 2
|
| if token.pos_ == "VERB":
|
| return 3
|
| if token.pos_ == "NOUN" and token.dep_ in {
|
| "attr",
|
| "acomp",
|
| "oprd",
|
| "pobj",
|
| "dobj",
|
| "appos",
|
| }:
|
| return 4
|
| if token.pos_ == "NOUN":
|
| return 5
|
| return 6
|
|
|
|
|
| def _inflect(lemma: str, token) -> str | None:
|
| forms = getInflection(lemma, tag=token.tag_)
|
| value = forms[0] if forms else lemma
|
| if not value or not _WORD.fullmatch(value) or " " in value or "_" in value:
|
| return None
|
| if token.text.isupper():
|
| return value.upper()
|
| if token.text[:1].isupper():
|
| return value[:1].upper() + value[1:]
|
| return value.lower()
|
|
|
|
|
| def _neighbor_words(doc, token) -> tuple[str, str]:
|
| left = ""
|
| right = ""
|
| if token.i > 0:
|
| prev = doc[token.i - 1]
|
| if prev.is_alpha:
|
| left = prev.text.lower()
|
| if token.i + 1 < len(doc):
|
| nxt = doc[token.i + 1]
|
| if nxt.is_alpha:
|
| right = nxt.text.lower()
|
| return left, right
|
|
|
|
|
| def _object_lemma(token) -> str:
|
| for child in token.children:
|
| if child.dep_ in {"dobj", "obj"}:
|
| return child.lemma_.lower()
|
| return ""
|
|
|
|
|
| def _phrase_zipf(word: str, left: str, right: str) -> float:
|
| scores = [zipf_frequency(word, "en")]
|
| if left:
|
| scores.append(zipf_frequency(f"{left} {word}", "en"))
|
| if right:
|
| scores.append(zipf_frequency(f"{word} {right}", "en"))
|
| return max(scores)
|
|
|
|
|
| def _disaster_vo_blocked(
|
| source_lemma: str,
|
| candidate_lemma: str,
|
| token,
|
| *,
|
| left: str,
|
| right: str,
|
| ) -> bool:
|
| """Hard meaning-break blockers used even in aggressive classical mode."""
|
| if source_lemma == candidate_lemma:
|
| return False
|
| obj = _object_lemma(token)
|
| src_v = zipf_frequency(source_lemma, "en")
|
| cand_v = zipf_frequency(candidate_lemma, "en")
|
| if token.pos_ == "VERB" and obj:
|
| src_vo = zipf_frequency(f"{source_lemma} {obj}", "en")
|
| cand_vo = zipf_frequency(f"{candidate_lemma} {obj}", "en")
|
| src_spec = src_vo - src_v
|
| cand_spec = cand_vo - cand_v
|
|
|
| if src_vo >= 3.8 and cand_v - src_v >= 0.35 and cand_spec <= src_spec + 0.08:
|
| return True
|
| if src_vo >= 3.8 and cand_vo >= src_vo and cand_spec + 0.08 < src_spec:
|
| return True
|
|
|
|
|
| if src_vo >= 4.0 and cand_vo >= 4.0 and cand_spec + 0.05 < src_spec:
|
| return True
|
|
|
| if obj and cand_spec < -0.08 and src_vo >= 4.0:
|
| return True
|
|
|
| if cand_spec < -0.30 and src_spec < -0.15 and cand_spec < src_spec + 0.40:
|
| return True
|
| if token.pos_ == "VERB" and right:
|
| src_bi = zipf_frequency(f"{source_lemma} {right}", "en")
|
| cand_bi = zipf_frequency(f"{candidate_lemma} {right}", "en")
|
|
|
| if (
|
| right == "promptly"
|
| and cand_v >= 5.0
|
| and cand_v - src_v >= 0.40
|
| and cand_bi + 0.10 >= src_bi
|
| ):
|
| return True
|
| return False
|
|
|
|
|
| def _collocation_ok(
|
| source_lemma: str,
|
| candidate_lemma: str,
|
| token,
|
| *,
|
| left: str,
|
| right: str,
|
| classical_strict: bool = False,
|
| headword_upgrade: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> bool:
|
| """Reject swaps that collapse local collocations (algorithmic, no denylist)."""
|
|
|
| if classical_aggressive and classical_strict and not headword_upgrade:
|
| if _disaster_vo_blocked(
|
| source_lemma, candidate_lemma, token, left=left, right=right
|
| ):
|
| return False
|
| classical_strict = False
|
| obj = _object_lemma(token)
|
| src_v = zipf_frequency(source_lemma, "en")
|
| cand_v = zipf_frequency(candidate_lemma, "en")
|
| checks: list[tuple[float, float]] = []
|
| if obj:
|
| checks.append(
|
| (
|
| zipf_frequency(f"{source_lemma} {obj}", "en"),
|
| zipf_frequency(f"{candidate_lemma} {obj}", "en"),
|
| )
|
| )
|
| if right:
|
| checks.append(
|
| (
|
| zipf_frequency(f"{source_lemma} {right}", "en"),
|
| zipf_frequency(f"{candidate_lemma} {right}", "en"),
|
| )
|
| )
|
| if left:
|
| checks.append(
|
| (
|
| zipf_frequency(f"{left} {source_lemma}", "en"),
|
| zipf_frequency(f"{left} {candidate_lemma}", "en"),
|
| )
|
| )
|
| for source_score, candidate_score in checks:
|
| slack = 0.35 if classical_strict else 0.55
|
| if source_score >= 2.2 and candidate_score + slack < source_score:
|
| return False
|
| if source_score >= 3.0 and candidate_score < (2.4 if classical_strict else 2.0):
|
| return False
|
| if source_score >= 3.5 and candidate_score + (
|
| 0.45 if classical_strict else 0.85
|
| ) < source_score:
|
| return False
|
| if source_score >= 4.0 and candidate_score < 2.0:
|
| return False
|
| if classical_strict and source_score >= 2.5:
|
|
|
|
|
| src_spec = source_score - src_v
|
| cand_spec = candidate_score - cand_v
|
| if headword_upgrade:
|
|
|
| if cand_spec + 0.05 < src_spec and cand_v - src_v < 0.90:
|
| return False
|
| else:
|
| if cand_spec + 0.02 < src_spec:
|
| return False
|
| if candidate_score > source_score and cand_spec < src_spec:
|
| return False
|
| if cand_v >= 5.15 and cand_v - src_v >= 0.45:
|
| return False
|
|
|
| if token.pos_ == "VERB" and obj:
|
| source_obj = zipf_frequency(f"{source_lemma} {obj}", "en")
|
| candidate_obj = zipf_frequency(f"{candidate_lemma} {obj}", "en")
|
| src_spec = source_obj - src_v
|
| cand_spec = candidate_obj - cand_v
|
| if classical_strict:
|
| if headword_upgrade:
|
| if candidate_obj + 0.05 < source_obj or candidate_obj < 2.0:
|
| return False
|
| if cand_spec + 0.05 < src_spec and cand_v - src_v < 0.90:
|
| return False
|
| return True
|
| if cand_spec + 0.02 < src_spec:
|
| return False
|
| if candidate_obj > source_obj and cand_spec < src_spec:
|
| return False
|
| if cand_v >= 5.15 and cand_v - src_v >= 0.45:
|
| return False
|
| if cand_spec < -0.30 and src_spec < -0.20 and cand_spec < src_spec + 0.55:
|
| return False
|
|
|
| if source_obj < 2.2 and candidate_obj < 2.2:
|
| return False
|
| if source_obj >= 2.0 and candidate_obj + 0.20 < source_obj:
|
| return False
|
| if candidate_obj < 2.0:
|
| return False
|
| if abs(cand_v - src_v) < 0.40 and cand_spec <= src_spec + 0.05:
|
| return False
|
| return True
|
| if source_obj >= 1.8 and candidate_obj + 0.35 < source_obj:
|
| return False
|
| if source_obj < 1.5 and candidate_obj < 1.5:
|
|
|
|
|
| return candidate_obj + 0.1 >= source_obj
|
| elif classical_strict and token.pos_ == "VERB":
|
|
|
| if cand_v >= 5.15 and cand_v - src_v >= 0.45:
|
| if not headword_upgrade or cand_v - src_v < 0.90:
|
| return False
|
| return True
|
|
|
|
|
| def _peer_cycle_blocked(
|
| source_lemma: str,
|
| candidate_lemma: str,
|
| source_frequency: float,
|
| candidate_frequency: float,
|
| *,
|
| classical_strict: bool,
|
| classical_aggressive: bool = False,
|
| allow_headword_upgrade: bool = False,
|
| ) -> bool:
|
| """Block synonym oscillation.
|
|
|
| Strict mode blocks near-peer swaps. Aggressive mode only blocks clear
|
| demotions (rarer candidate) so each pass can still diverge.
|
| """
|
| if not classical_strict or allow_headword_upgrade:
|
| return False
|
| if classical_aggressive:
|
|
|
|
|
| return candidate_frequency + 0.85 < source_frequency
|
| if candidate_frequency + 0.20 < source_frequency:
|
| return True
|
| if (
|
| abs(candidate_frequency - source_frequency) < 0.40
|
| and candidate_frequency < source_frequency + 0.45
|
| ):
|
| return True
|
| return False
|
|
|
|
|
| def _frequency_ok(
|
| source_frequency: float,
|
| candidate_frequency: float,
|
| *,
|
| classical_aggressive: bool = False,
|
| ) -> bool:
|
| if classical_aggressive:
|
|
|
| if candidate_frequency < max(3.2, ENGINE_LEXICAL_MIN_ZIPF - 0.8):
|
| return False
|
| return abs(candidate_frequency - source_frequency) <= 1.8
|
| if candidate_frequency < ENGINE_LEXICAL_MIN_ZIPF:
|
| return False
|
| if ENGINE_LEXICAL_PREFER_SIMPLER:
|
| if candidate_frequency > source_frequency:
|
| return (
|
| candidate_frequency - source_frequency
|
| <= ENGINE_LEXICAL_MAX_SIMPLER_GAP
|
| )
|
| return source_frequency - candidate_frequency <= ENGINE_LEXICAL_MAX_HARDER_GAP
|
| return (
|
| abs(candidate_frequency - source_frequency)
|
| <= ENGINE_LEXICAL_MAX_FREQUENCY_GAP
|
| )
|
|
|
|
|
| def _candidate_for_synset(
|
| synset,
|
| token,
|
| doc,
|
| *,
|
| classical_strict: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> tuple[str, float] | None:
|
| source = token.lemma_.lower()
|
| source_surface = token.text.lower()
|
| source_frequency = max(
|
| zipf_frequency(source, "en"),
|
| zipf_frequency(source_surface, "en"),
|
| )
|
| left, right = _neighbor_words(doc, token)
|
| source_phrase = _phrase_zipf(source_surface, left, right)
|
| try:
|
| words = synset.words()
|
| except Exception:
|
| return None
|
|
|
| ranked: list[tuple[float, float, float, str]] = []
|
| for word in words:
|
| lemma = (word.lemma() or "").replace("_", " ").strip().lower()
|
| if (
|
| not lemma
|
| or lemma == source
|
| or " " in lemma
|
| or not _WORD.fullmatch(lemma)
|
| ):
|
| continue
|
| if not _collocation_ok(
|
| source,
|
| lemma,
|
| token,
|
| left=left,
|
| right=right,
|
| classical_strict=classical_strict,
|
| headword_upgrade=False,
|
| classical_aggressive=classical_aggressive,
|
| ):
|
| continue
|
| replacement = _inflect(lemma, token)
|
| if not replacement or replacement.lower() == token.text.lower():
|
| continue
|
| candidate_frequency = zipf_frequency(lemma, "en")
|
| rank_frequency = zipf_frequency(replacement.lower(), "en")
|
| if not _frequency_ok(
|
| source_frequency,
|
| max(candidate_frequency, rank_frequency),
|
| classical_aggressive=classical_aggressive,
|
| ):
|
| continue
|
| if _peer_cycle_blocked(
|
| source,
|
| lemma,
|
| source_frequency,
|
| max(candidate_frequency, rank_frequency),
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| ):
|
| continue
|
|
|
|
|
| if (
|
| token.pos_ == "NOUN"
|
| and max(candidate_frequency, rank_frequency) >= 5.5
|
| and max(candidate_frequency, rank_frequency) - source_frequency >= 0.5
|
| ):
|
| continue
|
| phrase = _phrase_zipf(replacement.lower(), left, right)
|
| phrase_slack = (
|
| 0.55
|
| if classical_aggressive
|
| else (0.25 if classical_strict else 0.55)
|
| )
|
| if phrase + phrase_slack < source_phrase:
|
| continue
|
| if left and token.pos_ == "NOUN":
|
| source_bigram = zipf_frequency(f"{left} {source_surface}", "en")
|
| candidate_bigram = zipf_frequency(
|
| f"{left} {replacement.lower()}", "en"
|
| )
|
| if source_bigram >= 3.0 and candidate_bigram + 0.45 < source_bigram:
|
| continue
|
| if (
|
| token.pos_ == "NOUN"
|
| and left
|
| and token.i > 0
|
| and doc[token.i - 1].pos_ == "ADJ"
|
| and phrase > source_phrase + 0.25
|
| ):
|
| continue
|
| if not substitution_pos_stable(doc.text, token.i, replacement):
|
| continue
|
| ranked.append(
|
| (
|
| max(candidate_frequency, rank_frequency) - source_frequency,
|
| -abs(
|
| max(candidate_frequency, rank_frequency)
|
| - (
|
| source_frequency
|
| + (0.55 if ENGINE_LEXICAL_PREFER_SIMPLER else 0.0)
|
| )
|
| ),
|
| phrase,
|
| -float(len(replacement)),
|
| replacement,
|
| )
|
| )
|
|
|
| if not ranked:
|
| return None
|
|
|
|
|
| clear = [item for item in ranked if item[0] >= 0.45]
|
| pool = clear if clear else [item for item in ranked if item[0] >= 0.0]
|
| if not pool:
|
| pool = ranked
|
| if classical_aggressive:
|
|
|
| pool.sort(
|
| key=lambda item: (
|
| abs(len(item[4]) - len(source_surface))
|
| + sum(1 for a, b in zip(item[4].lower(), source_surface) if a != b),
|
| item[0],
|
| item[2],
|
| ),
|
| reverse=True,
|
| )
|
| else:
|
| pool.sort(key=lambda item: (item[1], item[0], item[2], item[3]), reverse=True)
|
| best = pool[0][4]
|
| return best, pool[0][0]
|
|
|
|
|
| def _article_for(word: str) -> str:
|
| return "an" if word[:1].lower() in {"a", "e", "i", "o", "u"} else "a"
|
|
|
|
|
| def _synset_single_word_lemmas(synset) -> list[str]:
|
| lemmas: list[str] = []
|
| try:
|
| words = synset.words()
|
| except Exception:
|
| return lemmas
|
| for word in words:
|
| lemma = (word.lemma() or "").replace("_", " ").strip().lower()
|
| if lemma and " " not in lemma and _WORD.fullmatch(lemma):
|
| lemmas.append(lemma)
|
| return lemmas
|
|
|
|
|
| def _source_lemma_rank(synset, source_lemma: str) -> int:
|
| """Lower rank = source is a more canonical member of this synset."""
|
| lemmas = _synset_single_word_lemmas(synset)
|
| try:
|
| return lemmas.index(source_lemma)
|
| except ValueError:
|
| return 99
|
|
|
|
|
| def _candidate_lemma_for_replacement(synset, token, replacement: str) -> str:
|
| """Map an inflected replacement back to a synset lemma when possible."""
|
| surface = replacement.lower()
|
| for lemma in _synset_single_word_lemmas(synset):
|
| form = _inflect(lemma, token)
|
| if form and form.lower() == surface:
|
| return lemma
|
| if lemma == surface:
|
| return lemma
|
| return surface
|
|
|
|
|
| def _unglossed_verb_allowed(
|
| synset,
|
| *,
|
| source_lemma: str,
|
| cand_lemma: str,
|
| stop_words: set[str],
|
| classical_strict: bool = False,
|
| ) -> bool:
|
| """Allow unglossed verb swaps only with strong sense evidence.
|
|
|
| Blocks preserve→continue while keeping purchase→buy, assist→help,
|
| construct→build, and require→need on canonical senses.
|
| """
|
| defn_terms = _terms_from_text(synset.definition(), stop_words)
|
| lemmas = _synset_single_word_lemmas(synset)
|
| others = [lemma for lemma in lemmas if lemma != source_lemma]
|
| source_rank = _source_lemma_rank(synset, source_lemma)
|
|
|
| if classical_strict and source_rank == 0 and cand_lemma in lemmas[1:]:
|
| return False
|
|
|
| if (
|
| cand_lemma in defn_terms
|
| and source_rank <= 2
|
| and len(lemmas) >= 3
|
| ):
|
| return True
|
|
|
| if (
|
| len(others) == 1
|
| and others[0] == cand_lemma
|
| and lemmas
|
| and lemmas[0] == cand_lemma
|
| and source_rank == 1
|
| ):
|
| defn_l = (synset.definition() or "").lower()
|
| if cand_lemma in defn_terms:
|
| return True
|
|
|
| if f"by {source_lemma}" in defn_l or f"of {source_lemma}" in defn_l:
|
| return True
|
|
|
| if (
|
| source_rank == 0
|
| and len(lemmas) >= 3
|
| and cand_lemma in lemmas[:3]
|
| and not classical_strict
|
| ):
|
| return True
|
| return False
|
|
|
|
|
| def _polish_headword_upgrade(synset, source_lemma: str, cand_lemma: str) -> bool:
|
| """Polish-only: allow source→headword upgrades in small verb synsets.
|
|
|
| Example: maintain→keep on keep/maintain/hold. Blocks identify→place where
|
| the source is already the headword.
|
| """
|
| lemmas = _synset_single_word_lemmas(synset)
|
| if not lemmas or lemmas[0] != cand_lemma:
|
| return False
|
| source_rank = _source_lemma_rank(synset, source_lemma)
|
| return 1 <= source_rank <= 4 and 2 <= len(lemmas) <= 5
|
|
|
|
|
| def _headword_candidate_for_synset(
|
| synset,
|
| token,
|
| doc,
|
| *,
|
| classical_strict: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> tuple[str, float] | None:
|
| """Try the synset headword directly for polish-only verb upgrades."""
|
| lemmas = _synset_single_word_lemmas(synset)
|
| if not lemmas:
|
| return None
|
| head = lemmas[0]
|
| replacement = _inflect(head, token)
|
| if not replacement or replacement.lower() == token.text.lower():
|
| return None
|
| source = token.lemma_.lower()
|
| source_surface = token.text.lower()
|
| source_frequency = max(
|
| zipf_frequency(source, "en"),
|
| zipf_frequency(source_surface, "en"),
|
| )
|
| left, right = _neighbor_words(doc, token)
|
| source_phrase = _phrase_zipf(source_surface, left, right)
|
| if not _collocation_ok(
|
| source,
|
| head,
|
| token,
|
| left=left,
|
| right=right,
|
| classical_strict=classical_strict,
|
| headword_upgrade=True,
|
| classical_aggressive=classical_aggressive,
|
| ):
|
| return None
|
| candidate_frequency = zipf_frequency(head, "en")
|
| rank_frequency = zipf_frequency(replacement.lower(), "en")
|
| if not _frequency_ok(
|
| source_frequency,
|
| max(candidate_frequency, rank_frequency),
|
| classical_aggressive=classical_aggressive,
|
| ):
|
| return None
|
| phrase = _phrase_zipf(replacement.lower(), left, right)
|
| phrase_slack = 0.55 if classical_aggressive else (0.25 if classical_strict else 0.55)
|
| if phrase + phrase_slack < source_phrase:
|
| return None
|
| if not substitution_pos_stable(doc.text, token.i, replacement):
|
| return None
|
| return replacement, max(candidate_frequency, rank_frequency) - source_frequency
|
|
|
|
|
| def _pick_verb_candidate(
|
| token,
|
| doc,
|
| synsets: list[Any],
|
| context: set[str],
|
| stop_words: set[str],
|
| *,
|
| effective_min: float,
|
| gain_floor: float,
|
| polish: bool = False,
|
| aggressive: bool = False,
|
| classical_strict: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> tuple[float, str, float, Any] | None:
|
| """Choose a verb synonym from a few top senses with collocational safety."""
|
| obj = _object_lemma(token)
|
| src_lemma = token.lemma_.lower()
|
| weak_gloss = max(float(effective_min), 0.25)
|
|
|
|
|
| adjectival_participle = token.tag_ == "VBG" and token.dep_ in {
|
| "amod",
|
| "acomp",
|
| "oprd",
|
| }
|
| options: list[tuple[float, int, float, float, str, Any]] = []
|
|
|
|
|
|
|
| sense_window = 3 if classical_strict else 6
|
| ordered = sorted(
|
| synsets[:sense_window],
|
| key=lambda synset: (
|
| _source_lemma_rank(synset, src_lemma),
|
| -_sense_score(synset, context, stop_words),
|
| ),
|
| )
|
| for synset in ordered:
|
| score = _sense_score(synset, context, stop_words)
|
| picked = _candidate_for_synset(
|
| synset,
|
| token,
|
| doc,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| headword_polish = False
|
| if (
|
| polish
|
| and not aggressive
|
| and score < weak_gloss
|
| and _source_lemma_rank(synset, src_lemma) >= 1
|
| ):
|
| headword_pick = _headword_candidate_for_synset(
|
| synset,
|
| token,
|
| doc,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if headword_pick is not None:
|
| picked = headword_pick
|
| headword_polish = True
|
| if not picked:
|
| continue
|
| replacement, simplicity_gain = picked
|
| if (
|
| score < effective_min
|
| and simplicity_gain < gain_floor
|
| and not headword_polish
|
| ):
|
| continue
|
| cand_lemma = _candidate_lemma_for_replacement(synset, token, replacement)
|
| high_gain_ok = False
|
| if score < weak_gloss:
|
| if adjectival_participle:
|
| continue
|
| unglossed_ok = _unglossed_verb_allowed(
|
| synset,
|
| source_lemma=src_lemma,
|
| cand_lemma=cand_lemma,
|
| stop_words=stop_words,
|
| classical_strict=classical_strict and not classical_aggressive,
|
| )
|
|
|
|
|
| high_gain_ok = (
|
| polish
|
| and simplicity_gain >= 0.08
|
| and _polish_headword_upgrade(synset, src_lemma, cand_lemma)
|
| )
|
| if classical_strict and high_gain_ok:
|
|
|
| if obj:
|
| src_c = zipf_frequency(f"{src_lemma} {obj}", "en")
|
| cand_c = zipf_frequency(f"{cand_lemma} {obj}", "en")
|
| if cand_c + 0.05 < src_c or cand_c < 2.0:
|
| high_gain_ok = False
|
| if not unglossed_ok and not high_gain_ok:
|
| continue
|
| multiword_parent = False
|
| try:
|
| for word in synset.words():
|
| multi = (word.lemma() or "").replace("_", " ").strip().lower()
|
| if multi.startswith(cand_lemma + " "):
|
| multiword_parent = True
|
| break
|
| except Exception:
|
| pass
|
| if multiword_parent:
|
| continue
|
| src_colloc = zipf_frequency(f"{src_lemma} {obj}", "en") if obj else 0.0
|
| cand_colloc = zipf_frequency(f"{cand_lemma} {obj}", "en") if obj else 0.0
|
|
|
|
|
| if obj and src_colloc >= 4.7 and score < weak_gloss and score < effective_min:
|
| continue
|
| if (
|
| score < weak_gloss
|
| and polish
|
| and high_gain_ok
|
| and obj
|
| and (
|
| cand_colloc + 0.05 < src_colloc
|
|
|
|
|
|
|
| or (src_colloc >= 4.2 and cand_colloc - src_colloc >= 0.45)
|
| )
|
| ):
|
| continue
|
| if classical_strict and obj and cand_colloc < 2.0:
|
| continue
|
| options.append(
|
| (
|
| score,
|
| _source_lemma_rank(synset, src_lemma),
|
| simplicity_gain,
|
| cand_colloc,
|
| replacement,
|
| synset,
|
| )
|
| )
|
| if not options:
|
| return None
|
| options.sort(key=lambda item: (-item[0], item[1], -item[2], -item[3]))
|
| score, _rank, gain, _colloc, replacement, synset = options[0]
|
| return (max(score, effective_min), replacement, gain, synset)
|
|
|
|
|
| def dynamic_lexical_budget(text: str, *, polish: bool = False) -> int:
|
| """Choose how many synonym swaps a sentence may take from its length.
|
|
|
| Longer sentences get a larger budget so wording density scales with size
|
| instead of a fixed environment cap.
|
| """
|
| words = len(_WORD.findall(text or ""))
|
| if words < 3:
|
| return 0
|
|
|
| stride = 4 if polish else 8
|
| budget = max(1, (words + stride - 1) // stride)
|
| if polish and words >= 5:
|
| budget = max(budget, 2)
|
|
|
| ceiling = (6 + words // 6) if polish else (4 + words // 10)
|
| budget = min(budget, max(1, min(ceiling, 15)))
|
| if ENGINE_LEXICAL_MAX_CHANGES > 0:
|
| budget = min(budget, ENGINE_LEXICAL_MAX_CHANGES)
|
| return budget
|
|
|
|
|
| def _related_modifier_synsets(synset) -> list[Any]:
|
| """WordNet-linked adjective/adverb senses (no hardcoded synonym lists)."""
|
| related: list[Any] = []
|
| seen: set[str] = set()
|
| for relation in ("also", "similar"):
|
| try:
|
| linked = list(synset.get_related(relation) or [])
|
| except Exception:
|
| linked = []
|
| for item in linked:
|
| syn_id = str(getattr(item, "id", item))
|
| if syn_id in seen:
|
| continue
|
| seen.add(syn_id)
|
| related.append(item)
|
| if len(related) >= 12:
|
| return related
|
| return related
|
|
|
|
|
| def _pick_related_modifier_candidate(
|
| token,
|
| doc,
|
| synsets: list[Any],
|
| *,
|
| gain_floor: float,
|
| classical_strict: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> tuple[float, str, float, Any] | None:
|
| """Polish helper: try WordNet related senses when same-synset synonyms are thin."""
|
| if classical_strict and not classical_aggressive:
|
| return None
|
| source_freq = max(
|
| zipf_frequency(token.lemma_.lower(), "en"),
|
| zipf_frequency(token.text.lower(), "en"),
|
| )
|
| options: list[tuple[float, float, str, Any]] = []
|
| for synset in synsets[:2]:
|
| for related in _related_modifier_synsets(synset):
|
| picked = _candidate_for_synset(
|
| related,
|
| token,
|
| doc,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if not picked:
|
| continue
|
| replacement, simplicity_gain = picked
|
| if simplicity_gain < gain_floor or simplicity_gain > 0.95:
|
| continue
|
| cand_freq = zipf_frequency(replacement.lower(), "en")
|
| if cand_freq < ENGINE_LEXICAL_MIN_ZIPF:
|
| continue
|
|
|
|
|
| if cand_freq >= 5.2:
|
| continue
|
| if cand_freq - source_freq >= 0.85:
|
| continue
|
| options.append((simplicity_gain, cand_freq, replacement, related))
|
| if not options:
|
| return None
|
| options.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
| gain, _freq, replacement, synset = options[0]
|
| return (max(0.12, gain_floor), replacement, gain, synset)
|
|
|
|
|
| def refine_sentence(
|
| text: str,
|
| *,
|
| min_wsd: float,
|
| max_changes: int | None = None,
|
| wordnet: Any | None = None,
|
| aggressive: bool = False,
|
| polish: bool = False,
|
| classical_strict: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> LexicalResult:
|
| """Replace low-impact words with simpler everyday synonyms when safe."""
|
| source = (text or "").strip()
|
| if not source:
|
| return LexicalResult(text=text, reason="empty")
|
| if any(quote in source for quote in _QUOTES):
|
| return LexicalResult(text=source, reason="quoted")
|
| if _CITATION.search(source):
|
| return LexicalResult(text=source, reason="citation")
|
| if _PROTECTED_MARKER.search(source):
|
| return LexicalResult(text=source, reason="protected")
|
|
|
| nlp = get_nlp()
|
| resource = wordnet if wordnet is not None else _get_wordnet()
|
| if nlp is None or resource is None:
|
| return LexicalResult(text=source, reason="resource_unavailable")
|
| try:
|
| doc = nlp(source)
|
| except Exception:
|
| return LexicalResult(text=source, reason="parse_failed")
|
|
|
| tight = classical_strict and not classical_aggressive
|
| if aggressive:
|
| if tight:
|
|
|
| return LexicalResult(text=source, reason="classical_strict_no_aggressive")
|
| effective_min = max(0.05, min_wsd * 0.5)
|
| verb_gain_floor = 0.08 if classical_aggressive else 0.40
|
| adj_adv_gain_floor = 0.0 if classical_aggressive else 0.15
|
| elif polish:
|
|
|
|
|
|
|
| effective_min = max(0.05, min_wsd * 0.6) if classical_aggressive else max(0.10, min_wsd * 0.85)
|
| verb_gain_floor = 0.08 if classical_aggressive else (0.45 if tight else 0.30)
|
| adj_adv_gain_floor = 0.0 if classical_aggressive else (0.20 if tight else 0.10)
|
| else:
|
| effective_min = max(0.08, min_wsd * 0.7) if classical_aggressive else (min_wsd if not tight else max(min_wsd, 0.18))
|
| verb_gain_floor = 0.15 if classical_aggressive else (0.45 if not tight else 0.55)
|
| adj_adv_gain_floor = 0.0 if not tight else 0.15
|
| stop_words = set(nlp.Defaults.stop_words)
|
| proposals: list[tuple[float, int, float, int, int, str, Any]] = []
|
| for token in doc:
|
| if not _eligible(token, doc):
|
| continue
|
|
|
| context = _context_terms(doc, token)
|
| try:
|
| synsets = list(
|
| resource.synsets(
|
| token.lemma_.lower(),
|
| pos=_POS_MAP[token.pos_],
|
| )
|
| )
|
| except Exception:
|
| continue
|
| if not synsets:
|
| continue
|
|
|
|
|
| if token.pos_ in {"NOUN", "VERB"}:
|
| if token.pos_ == "VERB":
|
| picked_verb = _pick_verb_candidate(
|
| token,
|
| doc,
|
| synsets,
|
| context,
|
| stop_words,
|
| effective_min=effective_min,
|
| gain_floor=verb_gain_floor,
|
| polish=polish,
|
| aggressive=aggressive,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if not picked_verb:
|
| continue
|
| best_score, replacement, simplicity_gain, chosen = picked_verb
|
| else:
|
| chosen = synsets[0]
|
| best_score = _sense_score(chosen, context, stop_words)
|
| picked = _candidate_for_synset(
|
| chosen,
|
| token,
|
| doc,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if not picked:
|
| continue
|
| replacement, simplicity_gain = picked
|
| noun_min = (
|
| effective_min if not aggressive else max(0.08, effective_min)
|
| )
|
| if (
|
| best_score < noun_min
|
| or simplicity_gain
|
| < (
|
| (-0.15 if classical_aggressive else 0.25)
|
| if polish and not aggressive
|
| else (-0.05 if classical_aggressive else 0.35)
|
| )
|
| or simplicity_gain
|
| > (1.8 if classical_aggressive else (1.45 if polish and not aggressive else 1.25))
|
| ):
|
| continue
|
| cand_freq = zipf_frequency(replacement.lower(), "en")
|
| if cand_freq >= 5.5 and simplicity_gain >= (
|
| 0.7 if polish and not aggressive else 0.5
|
| ):
|
| continue
|
| if best_score < effective_min:
|
| best_score = effective_min
|
| proposals.append(
|
| (
|
| best_score,
|
| _impact_rank(token),
|
| -simplicity_gain,
|
| token.idx,
|
| token.i,
|
| replacement,
|
| chosen,
|
| )
|
| )
|
| continue
|
| else:
|
| scored: list[tuple[float, Any]] = []
|
| for synset in synsets:
|
| scored.append((_sense_score(synset, context, stop_words), synset))
|
| scored.sort(key=lambda item: item[0], reverse=True)
|
| best_score, best_synset = scored[0]
|
| chosen = None
|
| if best_score >= effective_min:
|
| chosen = best_synset
|
| elif len(synsets) == 1:
|
| chosen = best_synset
|
| best_score = effective_min
|
| elif polish and not aggressive and best_score >= effective_min * 0.75:
|
| trial = _candidate_for_synset(
|
| best_synset,
|
| token,
|
| doc,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if trial is not None and trial[1] >= adj_adv_gain_floor:
|
| chosen = best_synset
|
| best_score = max(best_score, effective_min)
|
| elif ENGINE_LEXICAL_PREFER_SIMPLER and best_score == 0.0:
|
| trial = _candidate_for_synset(
|
| synsets[0],
|
| token,
|
| doc,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| gain_need = 0.25 if aggressive else 0.45
|
| if trial is not None and trial[1] >= gain_need:
|
| chosen = synsets[0]
|
| best_score = effective_min
|
| elif aggressive and best_score >= effective_min * 0.5:
|
| trial = _candidate_for_synset(
|
| best_synset,
|
| token,
|
| doc,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if trial is not None and trial[1] >= adj_adv_gain_floor:
|
| chosen = best_synset
|
| best_score = max(best_score, effective_min)
|
|
|
| picked = (
|
| _candidate_for_synset(
|
| chosen,
|
| token,
|
| doc,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if chosen is not None
|
| else None
|
| )
|
| if not picked and polish and not aggressive:
|
|
|
|
|
| related = _pick_related_modifier_candidate(
|
| token,
|
| doc,
|
| synsets,
|
| gain_floor=max(adj_adv_gain_floor, 0.25),
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if related:
|
| best_score, replacement, simplicity_gain, chosen = related
|
| picked = (replacement, simplicity_gain)
|
| if not picked:
|
| continue
|
| replacement, simplicity_gain = picked
|
| if aggressive and simplicity_gain < adj_adv_gain_floor and best_score < min_wsd:
|
| continue
|
| if polish and not aggressive and simplicity_gain < adj_adv_gain_floor:
|
| continue
|
| proposals.append(
|
| (
|
| best_score,
|
| _impact_rank(token),
|
| -simplicity_gain,
|
| token.idx,
|
| token.i,
|
| replacement,
|
| chosen,
|
| )
|
| )
|
|
|
| if not proposals:
|
| return LexicalResult(text=source, reason="no_confident_candidate")
|
|
|
| if max_changes is None:
|
| limit = dynamic_lexical_budget(source, polish=polish)
|
| else:
|
| limit = max(1, min(int(max_changes), 15))
|
| if classical_strict and not classical_aggressive:
|
| limit = min(limit, 2 if polish else 1)
|
| elif classical_aggressive:
|
|
|
| limit = max(limit, 5 if polish else 3)
|
| limit = min(limit, 10 if polish else 6)
|
| if limit <= 0:
|
| return LexicalResult(text=source, reason="budget_zero")
|
| proposals.sort(key=lambda item: (-item[0], item[1], item[2], item[4]))
|
| selected = proposals[:limit]
|
| output = source
|
| changes: list[LexicalChange] = []
|
| for confidence, _impact, _gain, offset, token_index, replacement, synset in sorted(
|
| selected,
|
| key=lambda item: item[3],
|
| reverse=True,
|
| ):
|
| token = doc[token_index]
|
| output = output[:offset] + replacement + output[offset + len(token.text) :]
|
| if token_index > 0 and doc[token_index - 1].lower_ in {"a", "an"}:
|
| prev = doc[token_index - 1]
|
| needed = _article_for(replacement)
|
| if prev.lower_ != needed:
|
| article = needed.capitalize() if prev.text[:1].isupper() else needed
|
| output = (
|
| output[: prev.idx]
|
| + article
|
| + output[prev.idx + len(prev.text) :]
|
| )
|
| changes.append(
|
| LexicalChange(
|
| original=token.text,
|
| replacement=replacement,
|
| token_index=token_index,
|
| lemma=token.lemma_,
|
| synset_id=str(getattr(synset, "id", synset)),
|
| confidence=round(confidence, 4),
|
| )
|
| )
|
| if polish and not aggressive and (classical_aggressive or not classical_strict) and len(changes) < limit:
|
|
|
|
|
|
|
| extra = refine_sentence(
|
| output,
|
| min_wsd=max(0.08, min_wsd * 0.8),
|
| max_changes=limit - len(changes),
|
| wordnet=resource,
|
| aggressive=True,
|
| polish=False,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if extra.changes and extra.text != output:
|
| seen = {
|
| (change.original.lower(), change.replacement.lower())
|
| for change in changes
|
| }
|
| appended = False
|
| for change in extra.changes:
|
| pair = (change.original.lower(), change.replacement.lower())
|
| if pair not in seen:
|
| changes.append(change)
|
| seen.add(pair)
|
| appended = True
|
| if appended:
|
| output = extra.text
|
| changes.sort(key=lambda change: change.token_index)
|
| confidence = min(change.confidence for change in changes)
|
| return LexicalResult(text=output, changes=changes, confidence=confidence)
|
|
|
|
|
| def ensure_wording_change(
|
| text: str,
|
| *,
|
| min_wsd: float = 0.08,
|
| max_changes: int | None = None,
|
| wordnet: Any | None = None,
|
| polish: bool = False,
|
| classical_strict: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> LexicalResult:
|
| """Last-resort meaning-safe wording change for still-unchanged sentences."""
|
| source = (text or "").strip()
|
| if not source:
|
| return LexicalResult(text=text, reason="empty")
|
|
|
| if classical_strict and not classical_aggressive:
|
|
|
| return LexicalResult(text=source, reason="classical_strict_no_ensure")
|
|
|
| aggressive = refine_sentence(
|
| source,
|
| min_wsd=min_wsd,
|
| max_changes=max_changes,
|
| wordnet=wordnet,
|
| aggressive=True,
|
| polish=polish,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if aggressive.changes and aggressive.text.strip() != source:
|
| aggressive.reason = aggressive.reason or "ensure_aggressive"
|
| return aggressive
|
|
|
| forced = _force_one_safe_swap(
|
| source,
|
| wordnet=wordnet,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if forced.changes and forced.text.strip() != source:
|
| return forced
|
| return LexicalResult(text=source, reason="ensure_unavailable")
|
|
|
| def _force_one_safe_swap(
|
| text: str,
|
| *,
|
| wordnet: Any | None = None,
|
| classical_strict: bool = False,
|
| classical_aggressive: bool = False,
|
| ) -> LexicalResult:
|
| """Pick one low-impact ADV/ADJ/VERB swap from the primary sense when possible."""
|
| source = (text or "").strip()
|
| nlp = get_nlp()
|
| resource = wordnet if wordnet is not None else _get_wordnet()
|
| if nlp is None or resource is None:
|
| return LexicalResult(text=source, reason="resource_unavailable")
|
| try:
|
| doc = nlp(source)
|
| except Exception:
|
| return LexicalResult(text=source, reason="parse_failed")
|
|
|
| stop_words = set(nlp.Defaults.stop_words)
|
| ranked: list[tuple[int, float, float, int, str, Any]] = []
|
| for token in doc:
|
| if not _eligible(token, doc):
|
| continue
|
| if token.pos_ not in {"ADV", "ADJ", "VERB"}:
|
| continue
|
| try:
|
| synsets = list(
|
| resource.synsets(token.lemma_.lower(), pos=_POS_MAP[token.pos_])
|
| )
|
| except Exception:
|
| continue
|
| if not synsets:
|
| continue
|
| context = _context_terms(doc, token)
|
| if token.pos_ == "VERB":
|
| picked_verb = _pick_verb_candidate(
|
| token,
|
| doc,
|
| synsets,
|
| context,
|
| stop_words,
|
| effective_min=0.08,
|
| gain_floor=0.30,
|
| aggressive=True,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if not picked_verb:
|
| continue
|
| sense, replacement, simplicity_gain, synset = picked_verb
|
| ranked.append(
|
| (
|
| _impact_rank(token),
|
| -simplicity_gain,
|
| -sense,
|
| token.i,
|
| replacement,
|
| synset,
|
| )
|
| )
|
| continue
|
|
|
|
|
| candidates = [synsets[0]]
|
| if len(synsets) > 1:
|
| scored = sorted(
|
| (
|
| (_sense_score(s, context, stop_words), idx, s)
|
| for idx, s in enumerate(synsets)
|
| ),
|
| reverse=True,
|
| )
|
| if scored[0][2] not in candidates:
|
| candidates.append(scored[0][2])
|
| for synset in candidates:
|
| picked = _candidate_for_synset(
|
| synset,
|
| token,
|
| doc,
|
| classical_strict=classical_strict,
|
| classical_aggressive=classical_aggressive,
|
| )
|
| if not picked:
|
| continue
|
| replacement, simplicity_gain = picked
|
| sense = _sense_score(synset, context, stop_words)
|
| if simplicity_gain < 0.08 and sense < 0.05:
|
| continue
|
| ranked.append(
|
| (
|
| _impact_rank(token),
|
| -simplicity_gain,
|
| -sense,
|
| token.i,
|
| replacement,
|
| synset,
|
| )
|
| )
|
| break
|
|
|
| if not ranked:
|
| return LexicalResult(text=source, reason="no_force_candidate")
|
| ranked.sort()
|
| impact, _gain, _sense, token_index, replacement, synset = ranked[0]
|
| token = doc[token_index]
|
| output = source[: token.idx] + replacement + source[token.idx + len(token.text) :]
|
| if token_index > 0 and doc[token_index - 1].lower_ in {"a", "an"}:
|
| prev = doc[token_index - 1]
|
| needed = _article_for(replacement)
|
| if prev.lower_ != needed:
|
| article = needed.capitalize() if prev.text[:1].isupper() else needed
|
| output = (
|
| output[: prev.idx] + article + output[prev.idx + len(prev.text) :]
|
| )
|
| change = LexicalChange(
|
| original=token.text,
|
| replacement=replacement,
|
| token_index=token_index,
|
| lemma=token.lemma_,
|
| synset_id=str(getattr(synset, "id", synset)),
|
| confidence=0.55,
|
| )
|
| return LexicalResult(
|
| text=output,
|
| changes=[change],
|
| confidence=0.55,
|
| reason="ensure_force_swap",
|
| )
|
|
|