"""Multilingual brain-rot heuristics — closes issue #234. Extends v0.69.0 Part E ``score_triviality`` + ``score_popularity_signal`` with per-language token + phrase bundles (en/es/fr/de/ru). Option A from the issue: per-language registry under ``soup_cli/utils/brain_rot_lang.py`` plus an optional ``lang`` parameter on the public scorers (default ``"en"`` so the v0.69.0 surface stays backward-compat). """ from __future__ import annotations import dataclasses import json import re from pathlib import Path from types import MappingProxyType import pytest from typer.testing import CliRunner from soup_cli.cli import app from soup_cli.utils import brain_rot, brain_rot_lang # Strip Rich's ANSI escape sequences before substring assertions — on narrow # Windows columns Rich can split a flag across colour-cycle escapes # (e.g. `\x1b[..m-\x1b[..m-lang`), so the literal `--lang` substring fails on # CI even though the rendered help looks correct. Mirrors tests/test_auto_tuning.py. _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") def _write(path: Path, text: str) -> Path: path.write_text(text, encoding="utf-8") return path # --------------------------------------------------------------------------- # Bundle registry # --------------------------------------------------------------------------- class TestSupportedLangs: def test_minimum_five_languages(self) -> None: # Acceptance criteria: en + es + fr + de + ru. for code in ("en", "es", "fr", "de", "ru"): assert code in brain_rot_lang.SUPPORTED_LANGS def test_supported_langs_is_frozenset(self) -> None: assert isinstance(brain_rot_lang.SUPPORTED_LANGS, frozenset) def test_lang_bundles_is_mapping_proxy(self) -> None: assert isinstance(brain_rot_lang._LANG_BUNDLES, MappingProxyType) def test_lang_bundles_immutable(self) -> None: # MappingProxyType refuses item assignment with TypeError. with pytest.raises(TypeError): brain_rot_lang._LANG_BUNDLES["en"] = None # type: ignore[index] def test_every_bundle_has_non_empty_tuples(self) -> None: for code in brain_rot_lang.SUPPORTED_LANGS: bundle = brain_rot_lang._LANG_BUNDLES[code] assert len(bundle.low_effort_tokens) > 0 assert len(bundle.clickbait_phrases) > 0 class TestBrainRotLangBundle: def test_frozen(self) -> None: # tuples-not-lists so the dataclass is genuinely immutable. en = brain_rot_lang._LANG_BUNDLES["en"] with pytest.raises(dataclasses.FrozenInstanceError): en.low_effort_tokens = ("lol",) # type: ignore[misc] def test_tokens_are_tuple(self) -> None: for code in brain_rot_lang.SUPPORTED_LANGS: bundle = brain_rot_lang._LANG_BUNDLES[code] assert isinstance(bundle.low_effort_tokens, tuple) assert isinstance(bundle.clickbait_phrases, tuple) def test_token_entries_are_strings(self) -> None: for code in brain_rot_lang.SUPPORTED_LANGS: bundle = brain_rot_lang._LANG_BUNDLES[code] for tok in bundle.low_effort_tokens: assert isinstance(tok, str) assert tok # non-empty assert "\x00" not in tok for phrase in bundle.clickbait_phrases: assert isinstance(phrase, str) assert phrase assert "\x00" not in phrase def test_construction_rejects_null_byte(self) -> None: with pytest.raises(ValueError): brain_rot_lang.BrainRotLangBundle( code="en", low_effort_tokens=("lol\x00",), clickbait_phrases=("click here",), ) with pytest.raises(ValueError): brain_rot_lang.BrainRotLangBundle( code="en", low_effort_tokens=("lol",), clickbait_phrases=("click\x00here",), ) def test_construction_rejects_empty_token(self) -> None: with pytest.raises(ValueError): brain_rot_lang.BrainRotLangBundle( code="en", low_effort_tokens=("",), clickbait_phrases=("x",), ) def test_construction_rejects_non_string(self) -> None: with pytest.raises(TypeError): brain_rot_lang.BrainRotLangBundle( code="en", low_effort_tokens=(42,), # type: ignore[arg-type] clickbait_phrases=("x",), ) def test_construction_rejects_non_tuple_tokens(self) -> None: with pytest.raises(TypeError): brain_rot_lang.BrainRotLangBundle( code="en", low_effort_tokens=["lol"], # type: ignore[arg-type] clickbait_phrases=("x",), ) def test_construction_rejects_invalid_code(self) -> None: with pytest.raises(ValueError): brain_rot_lang.BrainRotLangBundle( code="", low_effort_tokens=("a",), clickbait_phrases=("b",), ) # --------------------------------------------------------------------------- # get_lang_bundle # --------------------------------------------------------------------------- class TestGetLangBundle: def test_known(self) -> None: bundle = brain_rot_lang.get_lang_bundle("en") assert bundle.code == "en" def test_case_insensitive(self) -> None: assert brain_rot_lang.get_lang_bundle("EN").code == "en" assert brain_rot_lang.get_lang_bundle("Es").code == "es" def test_unknown_falls_back_to_en(self) -> None: # "xx" is not in the bundle set; helper falls back to en. assert brain_rot_lang.get_lang_bundle("xx").code == "en" def test_none_falls_back_to_en(self) -> None: assert brain_rot_lang.get_lang_bundle(None).code == "en" def test_bool_rejected(self) -> None: with pytest.raises(TypeError): brain_rot_lang.get_lang_bundle(True) # type: ignore[arg-type] def test_non_string_rejected(self) -> None: with pytest.raises(TypeError): brain_rot_lang.get_lang_bundle(42) # type: ignore[arg-type] def test_null_byte_rejected(self) -> None: with pytest.raises(ValueError): brain_rot_lang.get_lang_bundle("e\x00n") def test_oversize_rejected(self) -> None: with pytest.raises(ValueError): brain_rot_lang.get_lang_bundle("a" * 65) # --------------------------------------------------------------------------- # validate_lang_code (CLI surface) # --------------------------------------------------------------------------- class TestValidateLangCode: def test_known_lang(self) -> None: assert brain_rot_lang.validate_lang_code("en") == "en" assert brain_rot_lang.validate_lang_code("es") == "es" def test_auto_sentinel(self) -> None: # "auto" is the documented sentinel; validate returns it canonical. assert brain_rot_lang.validate_lang_code("auto") == "auto" def test_case_insensitive(self) -> None: assert brain_rot_lang.validate_lang_code("EN") == "en" assert brain_rot_lang.validate_lang_code("AUTO") == "auto" def test_unknown_rejected(self) -> None: # The strict validator (used by the CLI) rejects unknown codes — # get_lang_bundle's silent fallback is for the scorer surface. with pytest.raises(ValueError): brain_rot_lang.validate_lang_code("xx") def test_bool_rejected(self) -> None: with pytest.raises(TypeError): brain_rot_lang.validate_lang_code(True) # type: ignore[arg-type] def test_non_string_rejected(self) -> None: with pytest.raises(TypeError): brain_rot_lang.validate_lang_code(42) # type: ignore[arg-type] def test_empty_rejected(self) -> None: with pytest.raises(ValueError): brain_rot_lang.validate_lang_code("") def test_null_byte_rejected(self) -> None: with pytest.raises(ValueError): brain_rot_lang.validate_lang_code("e\x00n") def test_oversize_rejected(self) -> None: with pytest.raises(ValueError): brain_rot_lang.validate_lang_code("a" * 65) # --------------------------------------------------------------------------- # score_triviality / score_popularity_signal — per-language bundle # --------------------------------------------------------------------------- class TestScoreTrivialityMultilingual: def test_default_en_backward_compat(self) -> None: # Default lang (None) preserves v0.69.0 English behaviour. text = "lol!!!! omg!!!! lol omg!!! lol!!!" assert brain_rot.score_triviality(text) > 0.5 def test_explicit_en(self) -> None: text = "lol!!!! omg!!!! lol omg!!! lol!!!" assert brain_rot.score_triviality(text, lang="en") > 0.5 def test_spanish_slop_detected_with_es(self) -> None: # Spanish low-effort tokens repeated → high triviality when lang=es. text = "jaja!!! jeje jaja jiji!!! jaja!!! jeje!!!" score = brain_rot.score_triviality(text, lang="es") assert score > 0.5 def test_spanish_slop_undetected_with_en(self) -> None: # Same Spanish slop with lang=en gets a lower low-effort signal # (still some punctuation noise, but the keyword half goes silent). text = "jaja!!! jeje jaja jiji!!! jaja!!! jeje!!!" es_score = brain_rot.score_triviality(text, lang="es") en_score = brain_rot.score_triviality(text, lang="en") assert es_score > en_score def test_french_slop(self) -> None: text = "mdr!!! mdr ptdr lol!!! mdr ptdr mdr!!!" score = brain_rot.score_triviality(text, lang="fr") assert score > 0.5 def test_german_slop(self) -> None: # 'krass' / 'omg' / 'lol' are common low-effort tokens in DE chat. text = "krass!!! omg krass!!! lol krass!!! omg!!!" score = brain_rot.score_triviality(text, lang="de") assert score > 0.5 def test_russian_slop(self) -> None: # Russian low-effort tokens (transliteration of laughter). text = "ааа!!! лол!!! ааа лол ааа!!! ааа!!!" score = brain_rot.score_triviality(text, lang="ru") assert score > 0.5 def test_unknown_lang_falls_back_to_en(self) -> None: # Unknown ISO code: the silent fallback uses English heuristics, # so behaviour matches the lang=en path. text = "lol!!! omg!!! lol!!!" en_score = brain_rot.score_triviality(text, lang="en") xx_score = brain_rot.score_triviality(text, lang="xx") assert abs(en_score - xx_score) < 1e-9 def test_substantive_text_unaffected(self) -> None: # Substantive non-English text should NOT score high. text = ( "El mitocondria es la central energética de la célula porque " "convierte nutrientes en ATP mediante fosforilación oxidativa." ) score = brain_rot.score_triviality(text, lang="es") assert score < 0.5 class TestScorePopularitySignalMultilingual: def test_default_en_backward_compat(self) -> None: text = "click here for the top 10 you won't believe what happened next" assert brain_rot.score_popularity_signal(text) > 0.5 def test_spanish_clickbait(self) -> None: text = "no creerás lo que pasó después haz clic aquí" score = brain_rot.score_popularity_signal(text, lang="es") assert score > 0.5 def test_french_clickbait(self) -> None: text = "vous n'allez pas le croire cliquez ici top 10" score = brain_rot.score_popularity_signal(text, lang="fr") assert score > 0.5 def test_german_clickbait(self) -> None: text = "du wirst nicht glauben hier klicken top 10" score = brain_rot.score_popularity_signal(text, lang="de") assert score > 0.5 def test_russian_clickbait(self) -> None: text = "вы не поверите что произошло дальше нажмите здесь" score = brain_rot.score_popularity_signal(text, lang="ru") assert score > 0.5 def test_substantive_unaffected(self) -> None: text = "Explicación científica detallada de la fotosíntesis" assert brain_rot.score_popularity_signal(text, lang="es") < 0.5 # --------------------------------------------------------------------------- # score_row_brain_rot / score_dataset_brain_rot — lang threading # --------------------------------------------------------------------------- class TestScoreRowLang: def test_default_backward_compat(self) -> None: # The v0.69.0 single-arg form still works. row = {"text": "Long substantive paragraph with diverse vocabulary."} assert brain_rot.score_row_brain_rot(row) > 0.5 def test_lang_kwarg(self) -> None: row = {"text": "Explicación científica detallada de la fotosíntesis"} score = brain_rot.score_row_brain_rot(row, lang="es") assert 0.0 <= score <= 1.0 assert score > 0.5 def test_auto_falls_back_to_en_when_undetectable(self) -> None: # Too-short row → langdetect returns unknown → falls back to en. # Output stays in [0, 1] and matches the lang=en path. row = {"text": "hi"} score_auto = brain_rot.score_row_brain_rot(row, lang="auto") score_en = brain_rot.score_row_brain_rot(row, lang="en") assert score_auto == score_en def test_auto_uses_detected_lang( self, monkeypatch: pytest.MonkeyPatch ) -> None: # tdd-review HIGH: prove the auto path actually routes to the # detected bundle (not a tautology). Force the detector to return # "es" and assert lang="auto" matches lang="es" byte-for-byte. import soup_cli.utils.data_score as ds monkeypatch.setattr(ds, "_langdetect_fast", lambda text: "es") row = {"text": "jaja!!! jeje jaja jiji!!! jaja!!! jeje!!!"} es_score = brain_rot.score_row_brain_rot(row, lang="es") auto_score = brain_rot.score_row_brain_rot(row, lang="auto") assert auto_score == es_score def test_auto_silently_falls_back_when_detector_missing( self, monkeypatch: pytest.MonkeyPatch ) -> None: # tdd-review HIGH: acceptance criterion #3 — exercise the # langdetect-missing path. _langdetect_fast already returns None # on the missing-package path; we patch it to None to simulate # the [data-pro]-not-installed environment without touching # sys.modules. import soup_cli.utils.data_score as ds monkeypatch.setattr(ds, "_langdetect_fast", lambda text: None) row = {"text": "lol!!! omg!!! lol!!!"} en_score = brain_rot.score_row_brain_rot(row, lang="en") auto_score = brain_rot.score_row_brain_rot(row, lang="auto") assert auto_score == en_score def test_auto_silently_falls_back_on_detector_exception( self, monkeypatch: pytest.MonkeyPatch ) -> None: # tdd-review HIGH: a detector that raises (e.g. corrupted # langdetect data) must not crash the scoring loop — falls back # to en silently per the issue spec. import soup_cli.utils.data_score as ds def _boom(text: str) -> None: raise OSError("simulated detector failure") monkeypatch.setattr(ds, "_langdetect_fast", _boom) row = {"text": "lol!!! omg!!! lol!!!"} en_score = brain_rot.score_row_brain_rot(row, lang="en") auto_score = brain_rot.score_row_brain_rot(row, lang="auto") assert auto_score == en_score def test_auto_falls_back_when_detected_lang_unsupported( self, monkeypatch: pytest.MonkeyPatch ) -> None: # tdd-review HIGH: detector returns an ISO code we don't have a # bundle for (e.g. "zh") → fall back to en, do not crash. import soup_cli.utils.data_score as ds monkeypatch.setattr(ds, "_langdetect_fast", lambda text: "zh") row = {"text": "lol!!! omg!!! lol!!!"} en_score = brain_rot.score_row_brain_rot(row, lang="en") auto_score = brain_rot.score_row_brain_rot(row, lang="auto") assert auto_score == en_score class TestScoreDatasetLang: def test_lang_threaded(self) -> None: rows = [{"text": "jaja!!! jeje jaja jiji!!! jaja!!!"} for _ in range(5)] report = brain_rot.score_dataset_brain_rot(rows, lang="es") # Spanish slop should drag verdict down. assert report.overall_verdict in ("MAJOR", "MINOR") def test_default_backward_compat(self) -> None: # English happy path is unchanged from v0.69.0. rows = [ {"text": "Detailed scientific explanation of photosynthesis."} ] * 3 report = brain_rot.score_dataset_brain_rot(rows) assert report.overall_verdict == "OK" class TestDatasetLangEagerValidation: """python-review LOW #2 — empty-rows bypass for bad lang arg.""" def test_empty_rows_bool_lang_rejected(self) -> None: # Without eager validation, an empty rows list would bypass the # per-row resolver and silently accept any lang value. with pytest.raises(TypeError): brain_rot.score_dataset_brain_rot([], lang=True) # type: ignore[arg-type] def test_empty_rows_null_byte_rejected(self) -> None: with pytest.raises(ValueError): brain_rot.score_dataset_brain_rot([], lang="e\x00n") def test_empty_rows_oversize_rejected(self) -> None: with pytest.raises(ValueError): brain_rot.score_dataset_brain_rot([], lang="a" * 65) def test_refuse_empty_rows_bool_lang(self) -> None: with pytest.raises(TypeError): brain_rot.refuse_if_rotten([], lang=True) # type: ignore[arg-type] class TestRefuseIfRottenLang: def test_lang_threaded(self) -> None: rows = [{"text": "jaja!!! jeje jaja jiji!!! jaja!!!"} for _ in range(5)] with pytest.raises(ValueError, match="brain.?rot"): brain_rot.refuse_if_rotten( rows, max_major_fraction=0.1, lang="es" ) def test_default_backward_compat(self) -> None: rows = [{"text": "Detailed scientific overview"}] * 3 brain_rot.refuse_if_rotten(rows, max_major_fraction=0.5) # --------------------------------------------------------------------------- # CLI: `soup data brain-rot --lang` # --------------------------------------------------------------------------- class TestBrainRotCliLang: def test_lang_flag_in_help(self) -> None: runner = CliRunner() result = runner.invoke(app, ["data", "brain-rot", "--help"]) assert result.exit_code == 0, result.output assert "--lang" in _ANSI_RE.sub("", result.output) def test_lang_en_default( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) path = _write( tmp_path / "d.jsonl", "\n".join( json.dumps({"text": "Detailed scientific text " + str(i)}) for i in range(5) ) + "\n", ) runner = CliRunner() result = runner.invoke( app, ["data", "brain-rot", str(path), "--lang", "en"] ) assert result.exit_code == 0, result.output def test_lang_es( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) path = _write( tmp_path / "d.jsonl", "\n".join( json.dumps( {"text": "Explicación científica detallada número " + str(i)} ) for i in range(5) ) + "\n", ) runner = CliRunner() result = runner.invoke( app, ["data", "brain-rot", str(path), "--lang", "es"] ) assert result.exit_code == 0, result.output def test_lang_auto( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: # tdd-review MEDIUM #2: assert auto-canonicalisation reaches the # Lang row of the rendered Rich table. This proves the CLI flag # is wired end-to-end (not just accepted by Typer) — silent # under both langdetect-installed and -missing test envs. monkeypatch.chdir(tmp_path) path = _write( tmp_path / "d.jsonl", "\n".join( json.dumps({"text": "Detailed scientific text " + str(i)}) for i in range(5) ) + "\n", ) runner = CliRunner() result = runner.invoke( app, ["data", "brain-rot", str(path), "--lang", "auto"] ) assert result.exit_code == 0, result.output # The rendered table includes `│ Lang │ auto │` (with whitespace). assert "auto" in result.output def test_lang_auto_explicit_es_detection( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: # tdd-review MEDIUM #2 second prong: monkeypatch the detector to # return "es" and verify the CLI run completes successfully # (exercises the routing into the es bundle through the CLI). import soup_cli.utils.data_score as ds monkeypatch.setattr(ds, "_langdetect_fast", lambda text: "es") monkeypatch.chdir(tmp_path) path = _write( tmp_path / "d.jsonl", json.dumps({"text": "jaja jeje jaja jiji jaja"}) + "\n", ) runner = CliRunner() result = runner.invoke( app, ["data", "brain-rot", str(path), "--lang", "auto"] ) assert result.exit_code == 0, result.output def test_lang_unknown_exit_2( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) path = _write( tmp_path / "d.jsonl", json.dumps({"text": "x"}) + "\n" ) runner = CliRunner() result = runner.invoke( app, ["data", "brain-rot", str(path), "--lang", "xx"] ) assert result.exit_code == 2 def test_lang_case_insensitive_es( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) path = _write( tmp_path / "d.jsonl", json.dumps({"text": "x"}) + "\n", ) runner = CliRunner() result = runner.invoke( app, ["data", "brain-rot", str(path), "--lang", "ES"] ) assert result.exit_code == 0, result.output # --------------------------------------------------------------------------- # Source-wiring regression guards # --------------------------------------------------------------------------- class TestSourceWiring: def test_no_heavy_imports_in_brain_rot_lang(self) -> None: # tdd-review MEDIUM #1: do a per-line scan (not a substring scan # against "\nimport langdetect") so a top-level # `from langdetect import detect` is also caught. root = Path(__file__).resolve().parent.parent src = ( root / "src" / "soup_cli" / "utils" / "brain_rot_lang.py" ).read_text(encoding="utf-8") for line in src.splitlines(): stripped = line.strip() for forbidden in ( "import torch", "from torch", "import transformers", "from transformers", "import langdetect", "from langdetect", ): assert not stripped.startswith(forbidden), ( f"brain_rot_lang.py must not eager-import: {line!r}" ) def test_brain_rot_does_not_eager_import_langdetect(self) -> None: # langdetect is optional ([data-pro]); imports must stay lazy so # the brain-rot module loads on a bare install. root = Path(__file__).resolve().parent.parent src = ( root / "src" / "soup_cli" / "utils" / "brain_rot.py" ).read_text(encoding="utf-8") # No top-level langdetect import. for line in src.splitlines(): stripped = line.strip() if stripped.startswith("import langdetect") or stripped.startswith( "from langdetect" ): pytest.fail("brain_rot.py must not eager-import langdetect") def test_version_floor(self) -> None: from soup_cli import __version__ major_minor = tuple(int(x) for x in __version__.split(".")[:2]) # v0.69.x bullet — must ship in 0.69.0+. assert major_minor >= (0, 69) # --------------------------------------------------------------------------- # Regression: existing v0.69.0 English behaviour unchanged # --------------------------------------------------------------------------- class TestEnglishRegression: """Acceptance criterion: existing English behaviour unchanged.""" def test_english_low_effort_tokens_still_match(self) -> None: # Each v0.69.0 English low-effort token still triggers triviality. for tok in ("lol", "omg", "lmao", "rofl", "smh", "tbh", "idk"): text = " ".join([tok] * 8) assert brain_rot.score_triviality(text) > 0.5 def test_english_clickbait_still_matches(self) -> None: # tdd-review MEDIUM #3: tighten floor from > 0.0 to > 0.3 so an # accidental weight reduction is caught. With one phrase hit the # closed-form score is 0.7 * 0.5 = 0.35; a regression to half # weight would drop below 0.3 and trip the assertion. for phrase in ( "you won't believe", "top 10", "click here", "this one weird trick", ): score = brain_rot.score_popularity_signal(phrase) assert score > 0.3, (phrase, score) def test_english_bundle_contains_v069_tokens(self) -> None: # tdd-review LOW #2: pin the v0.69.0 EN tokens against silent # refactors of the bundle. Catches "I pruned 'lol' from the EN # list" before the behavioural test would. en = brain_rot_lang._LANG_BUNDLES["en"] for required in ("lol", "omg", "lmao", "rofl", "smh", "tbh", "idk"): assert required in en.low_effort_tokens, required for required in ("you won't believe", "top 10", "click here"): assert required in en.clickbait_phrases, required def test_substantive_english_still_ok(self) -> None: text = ( "The mitochondrion is the powerhouse of the cell because it " "converts nutrients into ATP through oxidative phosphorylation." ) row = {"text": text} assert brain_rot.score_row_brain_rot(row) > 0.5 # --------------------------------------------------------------------------- # Bundles cover all five required languages with non-empty unique entries # --------------------------------------------------------------------------- class TestAllBundlesPopulated: @pytest.mark.parametrize("code", ["en", "es", "fr", "de", "ru"]) def test_min_token_count(self, code: str) -> None: bundle = brain_rot_lang._LANG_BUNDLES[code] # Each language needs a meaningful baseline — at least 4 tokens + # 4 phrases (issue spec asks for 10-20 / 5-10; we relax to allow # under-resourced languages but enforce a non-trivial floor). assert len(bundle.low_effort_tokens) >= 4 assert len(bundle.clickbait_phrases) >= 4 @pytest.mark.parametrize("code", ["en", "es", "fr", "de", "ru"]) def test_no_duplicates_within_bundle(self, code: str) -> None: bundle = brain_rot_lang._LANG_BUNDLES[code] assert len(set(bundle.low_effort_tokens)) == len(bundle.low_effort_tokens) assert len(set(bundle.clickbait_phrases)) == len( bundle.clickbait_phrases ) @pytest.mark.parametrize("code", ["en", "es", "fr", "de", "ru"]) def test_phrases_lowercased(self, code: str) -> None: # Substring match runs against ``text.lower()`` — phrases must be # lowercased upfront or the match silently fails. bundle = brain_rot_lang._LANG_BUNDLES[code] for phrase in bundle.clickbait_phrases: assert phrase == phrase.lower(), phrase for tok in bundle.low_effort_tokens: assert tok == tok.lower(), tok