From 3c0a3e7475b9841d69b1d7dda3cb992edb55ccf3 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 21:04:41 +0000 Subject: [PATCH] fix(language-probe): degrade accented-Latin and mixed-script input explicitly (DG-011..014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script histogram called French/Spanish/Portuguese "fully covered" (100% Latin script) and a mixed English+Han prompt covered whenever the Han minority was small — so DG-011..014 ran English-only rules on text they cannot read: DG-011/013/014 answered with rule findings on non-English content, and DG-012 answered silent ready/100. The labeling guide (art_XPvHhPeZ) and each row's labeler_note prescribe the fix, not a relabel: "an ASCII-ratio/stop-word coverage probe must mark coverage ... Latin script alone must not silently pass", and for DG-013 "the uncovered-script probe still forces the degraded path regardless of any partial English detection". Two zero-dependency refinements to probe_script: - Function-word layer: small conservative stop-word lexicons for fr/es/pt (accent-stripped, edge-trimmed tokens; interior punctuation like example.com and de-serialization stays whole). A language fires at 3+ distinct hits beating English function-word evidence by a margin of 3, or 2+ hits when the input shows zero English function words. Detection routes to the degraded path; the note names the guessed language. - Uncovered-run layer: 3+ consecutive uncovered-script letters downgrade nominal full coverage to the degraded path (DG-013's 6-letter Han clause); a 2-letter borrow ("这个") still rides along, keeping the pinned "make it faster 这个" full-coverage contract. German/Italian/Dutch remain a documented blind spot (eval README labels exactly fr/es/pt + one mixed row as the v0.3 degradation contract). Evidence: probe verdicts for all 121 eval rows diffed against the release-tip baseline — exactly DG-011..014 change, nothing else. Baseline 362 tests untouched and green (381 total, 19 new). eval degradation false positives 3/14 -> 0/14; degradation-note honesty 10/14 -> 14/14; every other case_type byte-identical. Human author: Kalisetti Nihanth Naidu (nihanthnaidu007@gmail.com) Co-authored-by: Kalisetti Nihanth Naidu --- CHANGELOG.md | 6 + README.md | 11 ++ inputguard/language.py | 244 +++++++++++++++++++++++++++++++++++++++-- tests/test_language.py | 180 ++++++++++++++++++++++++++++++ 4 files changed, 430 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4879789..b16d681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ `heuristic_coverage`, `degradation_note` (included in `to_dict()`). - Mixed-script input: rules run whenever the covered share of letters is at least 50%, with a `partial` note between 50–70% coverage. +- Accented-Latin honesty (eval DG-011..014): French, Spanish, and + Portuguese input no longer passes as silently covered — a function-word + layer degrades them like any other uncovered language, and a run of 3+ + consecutive uncovered-script letters (mixed English+Han) degrades even + majority-English input. English prompts (loanwords, URLs, name and + timezone collisions) are pinned unchanged by tests. ## [0.2.0] — 2026-05-29 ### Added diff --git a/README.md b/README.md index 8c4d206..21a4cb9 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,17 @@ Three additive fields on the result carry the probe's verdict: Mixed input is handled by share, not by exclusion: `"make it faster 这个"` is still fully analyzed (English dominates and the rules run); input whose letters fall 50–70% inside coverage gets a `partial` note without a penalty. +Latin script alone is not coverage, though: the rules are English-only, and French, Spanish, or Portuguese text is 100% Latin script yet just as unreadable to them. The probe therefore also recognizes those three languages by their function words — enough distinct stop-word hits, with a margin over the input's English function-word evidence, degrades the input like any other uncovered language. German, Italian, Dutch, and other Latin-script languages are a documented blind spot and still pass as before. And a run of 3+ consecutive letters in an uncovered script degrades even a majority-English input ("Fix this bug 修复这个错误 in the payment flow"); a 2-letter borrow like `"这个"` still rides along. + +```python +result = guard.analyze("Preciso de um aplicativo web com login de usuário e relatórios") + +result.detected_language # 'pt' (function-word guess) +result.heuristic_coverage # 'none' +result.status # 'usable_with_warnings' — never 'ready' +result.degradation_note # names Portuguese (pt) and explains the skip +``` + --- ## How intent detection works diff --git a/inputguard/language.py b/inputguard/language.py index 13be44e..38d4e3f 100644 --- a/inputguard/language.py +++ b/inputguard/language.py @@ -18,6 +18,26 @@ ``ready``. Scripts with coverage (Latin) behave exactly as they did before the probe existed. +Latin script alone is not sufficient coverage, though: the heuristics are +English-only, and French, Spanish, or Portuguese text is 100% Latin script +yet just as unreadable to them as Chinese. Two refinements close that gap, +still with zero dependencies (eval rows DG-011..014): + +- **Function-word layer** — when the dominant script is Latin, the sample's + tokens are matched against small stop-word lexicons for French, Spanish, + and Portuguese. A language is "detected" only when it has at least + ``_MIN_DISTINCT_HITS`` distinct stop-word hits AND those beats the + input's English function-word evidence by ``_MIN_ENGLISH_MARGIN`` — so + genuinely English prompts (rich in English function words) can never + trip it over scattered collisions ("Pour the DES dump into LA storage"). + The lexicon languages are a documented subset: German, Italian, Dutch, + and other Latin-script languages still pass as before. +- **Uncovered-run layer** — a run of at least ``_UNCOVERED_BLOCK_DEGRADES_AT`` + consecutive letters in an uncovered script (e.g. a Han clause inside an + English sentence) downgrades nominal full coverage to the degraded path. + A two-character borrow like "这个" rides along (rules still run on the + English majority); a whole clause the rules cannot read cannot. + The probe is pure and thread-safe: no module-level mutation, only stdlib lookups, matching the ``analyze()`` thread-safety contract. """ @@ -157,6 +177,104 @@ "khmer": "km", } +# --------------------------------------------------------------------------- +# Latin-script language layer (eval rows DG-011, DG-012, DG-014) +# +# The script histogram alone cannot see French, Spanish, or Portuguese: their +# letters are Latin, so a purely script-level probe reports 100% coverage and +# the English-only heuristics run on text they cannot read — the same silent +# ready the probe exists to prevent. The labeling guide's rubric for these +# rows prescribes the fix: a stop-word probe that refuses to let "Latin +# script alone silently pass". +# +# The lexicons are deliberately tiny and conservative — function words +# (articles, pronouns, prepositions, conjunctions, question words) only: +# - every entry is >= 2 letters and is not a standalone English word, so +# scattered collisions (French "pour" = English "pour", "est" = the EST +# timezone) cannot manufacture a detection on their own; +# - matching runs on accent-stripped tokens ("qué" -> "que") and on tokens +# trimmed of edge punctuation ("¿Por" -> "por"), never on interior +# punctuation ("example.com" or "de-serialization" stay single tokens and +# cannot match "com" / "de"); +# - detection additionally requires the margin rule in ``_latin_language_of`` +# (distinct non-English hits must beat English function-word evidence by +# ``_MIN_ENGLISH_MARGIN``), so genuinely English prompts — which are dense +# in English function words — cannot trip the layer at all. +# +# This is an honest documented subset: German, Italian, Dutch, and other +# Latin-script languages still pass as before, because a word list for every +# language is neither shippable nor zero-dependency-honest. The probe reports +# a *guess* ("guessed language"), never a certainty. +# --------------------------------------------------------------------------- +_MIN_DISTINCT_HITS = 3 +_MIN_ENGLISH_MARGIN = 3 +# When the input shows zero English function-word evidence, two distinct +# foreign function words already fire: "Oui merci beaucoup pour ton aide" +# carries no English function words at all, and demanding three would miss +# it. A collision-only English prompt always has English evidence (the/a/ +# and/...), so the relaxed floor cannot trip it. +_MIN_SOLO_HITS = 2 + +# Run length (in consecutive letters) of an uncovered script inside an +# otherwise-covered input that forces the degraded path (eval row DG-013). +# A 1-2 letter borrow ("这个") rides along under the English majority; a run +# of 3+ letters is a word or clause the English heuristics cannot read. +_UNCOVERED_BLOCK_DEGRADES_AT = 3 + +_LATIN_FUNCTION_WORDS: Dict[str, frozenset] = { + "en": frozenset({ + "a", "an", "the", "and", "or", "but", "if", "then", "of", "to", "in", + "on", "at", "by", "for", "with", "from", "into", "over", "under", + "up", "down", "out", "off", "about", "after", "before", "between", + "during", "through", "without", "within", "is", "are", "was", "were", + "be", "been", "being", "am", "do", "does", "did", "have", "has", + "had", "will", "would", "can", "could", "should", "shall", "may", + "might", "must", "i", "you", "he", "she", "it", "we", "they", "me", + "him", "us", "them", "my", "your", "his", "its", "our", "their", + "this", "that", "these", "those", "there", "here", "who", "whom", + "whose", "which", "what", "when", "where", "why", "how", "all", + "any", "some", "each", "every", "both", "few", "many", "much", + "more", "most", "very", "too", "also", "just", "only", "again", + "once", "now", "not", "no", "yes", "so", "such", "than", "as", + "because", "while", "per", "via", "please", + }), + "es": frozenset({ + "el", "los", "las", "una", "unos", "unas", "mi", "mis", "tu", "tus", + "su", "sus", "pero", "para", "por", "que", "como", "donde", + "cuando", "cual", "cuanto", "esto", "esta", "este", "eso", "ese", + "esa", "esos", "esas", "con", "sin", "se", "es", "muy", "mas", + "soy", "eres", "puedo", "puedes", "quiero", "necesito", "gracias", + "hola", "porque", "tambien", "de", + }), + "fr": frozenset({ + "le", "la", "les", "des", "un", "une", "du", "au", "aux", "et", + "est", "dans", "pour", "avec", "sur", "ce", "cet", "cette", "ces", + "je", "tu", "il", "elle", "nous", "vous", "ils", "elles", "mon", + "ton", "son", "mes", "tes", "ses", "leur", "leurs", "qui", "que", + "quoi", "dont", "ou", "mais", "donc", "chez", "tres", "aussi", + "pourquoi", "etre", "avoir", "sans", "entre", "de", + }), + "pt": frozenset({ + "um", "uma", "uns", "umas", "de", "da", "dos", "das", "na", "nas", + "nos", "com", "para", "por", "que", "como", "muito", "mas", "meu", + "minha", "meus", "minhas", "isso", "isto", "estou", "preciso", + "obrigado", "obrigada", "quero", "posso", "onde", "porque", + "tambem", "seu", "sua", "sem", + }), +} + +# Note-facing language names for lexicon-detected Latin-script inputs. +_LATIN_LANGUAGE_NAMES = {"fr": "French", "es": "Spanish", "pt": "Portuguese"} + +# Punctuation that French/Spanish/Portuguese elides into the next word +# ("l'homme", "n'est") — split it so the pieces can match their lexicons. +_APOSTROPHES = str.maketrans({"'": " ", "\u2019": " ", "`": " "}) + +# Trim edge punctuation ("¿Por" -> "Por", "bord." -> "bord") while keeping +# interior punctuation intact ("example.com" stays one token, so a URL can +# never contribute a stray "com" hit). +_EDGE_TRIM = re.compile(r"^[^\w]+|[^\w]+$") + @dataclass(frozen=True) class ScriptProbe: @@ -164,12 +282,19 @@ class ScriptProbe: ``heuristic_coverage`` is the field ``analyze()`` branches on; the other fields feed the degradation note and the result's ``detected_language``. + + ``uncovered_block_script`` / ``uncovered_block_length`` record the longest + run of consecutive uncovered-script letters in the sample (0 when every + classified letter is Latin). They explain *why* a Latin-dominant input + was degraded — the mixed English+Han case — and feed the note's wording. """ detected_language: str dominant_script: Optional[str] covered_share: float heuristic_coverage: str + uncovered_block_script: Optional[str] = None + uncovered_block_length: int = 0 def _sample(text: str, limit: int) -> str: @@ -203,6 +328,54 @@ def _script_of(char: str) -> Optional[str]: return None +def _strip_diacritics(token: str) -> str: + """Accent-fold one token for lexicon matching: "qué" -> "que". + + NFD decomposition splits accented letters into base letter + combining + mark; the marks (category Mn) drop out. Purely lexical — the histogram + itself keeps every letter, accented or not. + """ + decomposed = unicodedata.normalize("NFD", token) + return "".join(ch for ch in decomposed if unicodedata.category(ch) != "Mn") + + +def _latin_language_of(sample: str) -> Optional[str]: + """Guess a non-English Latin-script language from function words, or ``None``. + + A language "fires" only when its distinct stop-word hits reach + :data:`_MIN_DISTINCT_HITS` AND beat the input's distinct English + function-word hits by :data:`_MIN_ENGLISH_MARGIN` — English prompts are + dense in English function words, so scattered collisions with French or + Spanish tokens ("pour", "est", "la") can never fire the layer alone. + Ties between firing languages resolve alphabetically, like the script + histogram's dominant-script tie-break. + """ + distinct: Dict[str, set] = {} + for raw in sample.translate(_APOSTROPHES).lower().split(): + token = _EDGE_TRIM.sub("", raw) + if not token: + continue + stripped = _strip_diacritics(token) + for lang, words in _LATIN_FUNCTION_WORDS.items(): + if stripped in words: + distinct.setdefault(lang, set()).add(stripped) + english = len(distinct.get("en", set())) + # English-dense inputs need the full margin; zero-English inputs fire on + # the relaxed floor (see _MIN_SOLO_HITS). + floor = _MIN_DISTINCT_HITS if english else _MIN_SOLO_HITS + firing = [ + (len(words), lang) + for lang, words in distinct.items() + if lang != "en" + and len(words) >= floor + and (english == 0 or len(words) >= english + _MIN_ENGLISH_MARGIN) + ] + if not firing: + return None + firing.sort(key=lambda item: (-item[0], item[1])) + return firing[0][1] + + def probe_script(text: str) -> ScriptProbe: """Classify an input's script coverage with a :mod:`unicodedata` histogram. @@ -211,16 +384,34 @@ def probe_script(text: str) -> ScriptProbe: letters whose script the probe recognizes; scripts outside :data:`_SCRIPT_KEYWORDS` (Runic, Deseret, ...) leave the covered-share denominator, which is the honest signal that heuristics do not apply. + + On top of the share histogram, two refinements route inputs the share + alone would wrongly call fully covered to the degraded path: Latin-script + text recognized as French/Spanish/Portuguese by function words + (:func:`_latin_language_of`), and Latin-dominant text carrying a run of + uncovered-script letters too long to ride along (``_UNCOVERED_BLOCK_DEGRADES_AT``). """ letter_count = 0 script_counts: Dict[str, int] = {} - for char in _sample(text, _SAMPLE_LIMIT): + uncovered_run = 0 + max_uncovered_run = 0 + max_uncovered_script: Optional[str] = None + sample = _sample(text, _SAMPLE_LIMIT) + for char in sample: if not unicodedata.category(char).startswith("L"): + uncovered_run = 0 # any non-letter breaks a run continue letter_count += 1 script = _script_of(char) if script is not None: script_counts[script] = script_counts.get(script, 0) + 1 + if script == "latin": + uncovered_run = 0 + else: + uncovered_run += 1 + if uncovered_run > max_uncovered_run: + max_uncovered_run = uncovered_run + max_uncovered_script = script classified = sum(script_counts.values()) if classified == 0: @@ -231,25 +422,43 @@ def probe_script(text: str) -> ScriptProbe: dominant_script=None, covered_share=0.0, heuristic_coverage=coverage, + uncovered_block_script=max_uncovered_script if letter_count > 0 else None, + uncovered_block_length=max_uncovered_run if letter_count > 0 else 0, ) covered_share = script_counts.get("latin", 0) / classified - if covered_share >= _FULL_COVERAGE_AT: - coverage = COVERAGE_FULL - elif covered_share >= _NONE_COVERAGE_BELOW: - coverage = COVERAGE_PARTIAL - else: - coverage = COVERAGE_NONE - # Deterministic dominant script: highest count, alphabetical tie-break. dominant = min(script_counts, key=lambda s: (-script_counts[s], s)) - detected_language = _detected_language(dominant, script_counts) + + latin_language = _latin_language_of(sample) if dominant == "latin" else None + if latin_language is not None: + # French/Spanish/Portuguese: 100% Latin script, yet the English-only + # heuristics cannot read a word of it — degraded like any other + # uncovered language, never a silent pass on nominal coverage. + coverage = COVERAGE_NONE + detected_language = latin_language + else: + detected_language = _detected_language(dominant, script_counts) + if covered_share >= _FULL_COVERAGE_AT: + if max_uncovered_run >= _UNCOVERED_BLOCK_DEGRADES_AT: + # Latin-dominant but carrying a substantive uncovered-script + # clause (mixed English+Han, DG-013): the rules cannot read + # that content, so full coverage is a false claim. + coverage = COVERAGE_NONE + else: + coverage = COVERAGE_FULL + elif covered_share >= _NONE_COVERAGE_BELOW: + coverage = COVERAGE_PARTIAL + else: + coverage = COVERAGE_NONE return ScriptProbe( detected_language=detected_language, dominant_script=dominant, covered_share=covered_share, heuristic_coverage=coverage, + uncovered_block_script=max_uncovered_script, + uncovered_block_length=max_uncovered_run, ) @@ -263,11 +472,16 @@ def _detected_language(dominant: str, script_counts: Dict[str, int]) -> str: def degradation_note_for(probe: ScriptProbe) -> str: """The note carried by results on the degraded (uncovered) path.""" - if probe.dominant_script is None: + if probe.detected_language in _LATIN_LANGUAGE_NAMES: + script_desc = ( + f"the {probe.dominant_script} script, but function words read as " + f"{_LATIN_LANGUAGE_NAMES[probe.detected_language]}" + ) + elif probe.dominant_script is None: script_desc = "a script outside the probe's coverage" else: script_desc = f"the {probe.dominant_script} script" - return ( + note = ( f"InputGuard's clarity rules are English-language heuristics; this " f"input appears to use {script_desc} (guessed language: " f"{probe.detected_language}), which they do not cover. Rule analysis " @@ -276,6 +490,14 @@ def degradation_note_for(probe: ScriptProbe) -> str: "input's clarity. Rephrasing the key details in English enables a " "full analysis." ) + if probe.uncovered_block_length >= _UNCOVERED_BLOCK_DEGRADES_AT: + note += ( + " The input also mixes scripts: a run of " + f"{probe.uncovered_block_length} letters in " + f"{probe.uncovered_block_script or 'an uncovered'} script sits " + "outside the heuristics' coverage." + ) + return note def partial_coverage_note(probe: ScriptProbe) -> str: diff --git a/tests/test_language.py b/tests/test_language.py index 997a1ab..3481bc0 100644 --- a/tests/test_language.py +++ b/tests/test_language.py @@ -427,3 +427,183 @@ def run(pair): for text, mode, result in results: baseline = expected_strict if mode == "strict" else expected assert result == baseline[text] + + +# --------------------------------------------------------------------------- +# Accented-Latin and mixed-script coverage (eval rows DG-011..014) +# +# The share histogram alone calls French/Spanish/Portuguese "fully covered" +# (their letters are Latin) and calls a mixed English+Han prompt covered +# whenever the Han minority is small. Both are silent mis-coverage: the +# English-only heuristics cannot read a word of them. These tests pin the +# two refinements that close the gap and the English prompts they must not +# disturb. +# --------------------------------------------------------------------------- + +FRENCH = "Crée une application web avec connexion utilisateur et tableau de bord" +SPANISH = "¿Por qué mi código se ejecuta tan lento y cómo puedo optimizarlo?" +PORTUGUESE = "Preciso de um aplicativo web com login de usuário e relatórios" +MIXED_ENGLISH_HAN = "Fix this bug 修复这个错误 in the payment flow" + + +def test_french_is_not_english_coverage(): + probe = probe_script(FRENCH) + assert probe.dominant_script == "latin" + assert probe.detected_language == "fr" + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_spanish_is_not_english_coverage(): + probe = probe_script(SPANISH) + assert probe.dominant_script == "latin" + assert probe.detected_language == "es" + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_portuguese_is_not_english_coverage(): + probe = probe_script(PORTUGUESE) + assert probe.dominant_script == "latin" + assert probe.detected_language == "pt" + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_french_degrades_even_with_only_two_stop_words(): + # Zero English function-word evidence: two distinct French function + # words already fire (_MIN_SOLO_HITS) — "Oui merci beaucoup pour ton + # aide" must not pass as English just because it is stop-word light. + probe = probe_script("Oui merci beaucoup pour ton aide") + assert probe.detected_language == "fr" + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_mixed_english_han_clause_is_degraded_by_run_length(): + probe = probe_script(MIXED_ENGLISH_HAN) + assert probe.dominant_script == "latin" + assert probe.detected_language == "en" # majority English, honestly kept + assert probe.heuristic_coverage == COVERAGE_NONE + assert probe.uncovered_block_script == "han" + assert probe.uncovered_block_length == 6 + + +def test_two_letter_han_borrow_stays_full(): + # Boundary guard for the run rule: a 2-letter borrow rides along under + # the English majority (the pinned "make it faster 这个" contract). + probe = probe_script("optimize the query 支持") + assert probe.detected_language == "en" + assert probe.heuristic_coverage == COVERAGE_FULL + + +def test_three_letter_han_block_degrades(): + # A 3-letter uncovered run is a word the English heuristics cannot read. + probe = probe_script("optimize the query 数据库") + assert probe.heuristic_coverage == COVERAGE_NONE + assert probe.uncovered_block_length == 3 + + +def test_accented_english_loanwords_stay_full(): + probe = probe_script("Add a café section for José résumé page") + assert probe.detected_language == "en" + assert probe.heuristic_coverage == COVERAGE_FULL + + +def test_urls_and_hyphenated_words_stay_full(): + # Interior punctuation keeps "example.com" and "de-duplicate" whole — + # neither may contribute a stray "com"/"de" lexicon hit. + probe = probe_script("De-duplicate records from api.example.com and deploy") + assert probe.detected_language == "en" + assert probe.heuristic_coverage == COVERAGE_FULL + + +def test_english_function_words_block_lexicon_fire(): + # Collision-heavy but genuinely English: pour/DES/LA/Mon are scattered + # collisions, and the dense English function-word evidence (the, into, + # before) keeps the margin rule from firing. + probe = probe_script("Pour the DES dump into LA storage before Mon") + assert probe.detected_language == "en" + assert probe.heuristic_coverage == COVERAGE_FULL + + +def test_unlisted_latin_language_stays_full(): + # Documented subset boundary: German is Latin script and not in the + # lexicons — it passes as before rather than pretending to degrade. + probe = probe_script("Ich möchte eine Webanwendung mit Benutzeranmeldung") + assert probe.detected_language == "en" + assert probe.heuristic_coverage == COVERAGE_FULL + + +def test_latin_language_probe_is_deterministic(): + assert probe_script(FRENCH) == probe_script(FRENCH) + assert probe_script(MIXED_ENGLISH_HAN) == probe_script(MIXED_ENGLISH_HAN) + + +def test_latin_language_degrades_end_to_end_like_other_uncovered_languages(): + # The DG-001..010 contract, now for the accented-Latin rows: explicit + # note, no silent ready, rules skipped, undetermined intent, no gaps. + for text, language in ((FRENCH, "fr"), (SPANISH, "es"), (PORTUGUESE, "pt")): + r = InputGuard().analyze(text) + assert r.heuristic_coverage == COVERAGE_NONE, text + assert r.detected_language == language, text + assert r.degradation_note is not None, text + assert r.status == "usable_with_warnings", text + assert r.detected_intent == DEGRADED_INTENT, text + assert r.gaps == [], text + assert r.clarity_score == 100 - DEGRADATION_PENALTY, text + + +def test_spanish_no_longer_returns_silent_ready(): + # DG-012's specific regression: v0.3-before-this-fix answered ready/100 + # with zero findings on Spanish input. + r = InputGuard().analyze(SPANISH) + assert r.status != "ready" + assert r.clarity_score != 100 + assert not r.is_clear() + + +def test_french_degrades_in_strict_mode_too(): + r = InputGuard(mode="strict").analyze(FRENCH) + assert r.status == "needs_clarification" + assert r.degradation_note is not None + + +def test_mixed_english_han_degrades_end_to_end(): + r = InputGuard().analyze(MIXED_ENGLISH_HAN) + assert r.heuristic_coverage == COVERAGE_NONE + assert r.degradation_note is not None + assert r.status == "usable_with_warnings" + assert r.detected_intent == DEGRADED_INTENT + assert r.gaps == [] + + +def test_french_note_names_the_language(): + note = InputGuard().analyze(FRENCH).degradation_note + assert "French" in note + assert "fr" in note + assert "English" in note + + +def test_mixed_note_names_the_uncovered_run(): + note = InputGuard().analyze(MIXED_ENGLISH_HAN).degradation_note + assert "han" in note + assert "6" in note + assert "English" in note + + +def test_all_fourteen_degradation_rows_meet_the_dg_rubric(): + # The acceptance sweep for DG-001..014 (labeling guide rubric: explicit + # degradation note, never a silent ready, no rule firing on vocabulary + # the heuristics cannot read). Labels come straight from eval/cases.csv. + import csv + from pathlib import Path + + cases = Path(__file__).resolve().parent.parent / "eval" / "cases.csv" + with cases.open(newline="", encoding="utf-8") as handle: + dg_rows = [row for row in csv.DictReader(handle) if row["id"].startswith("DG-")] + assert len(dg_rows) == 14 + + guard = InputGuard() + for row in dg_rows: + result = guard.analyze(row["text"], domain=row["domain"]) + assert result.degradation_note, row["id"] + assert result.status != "ready", row["id"] + assert result.heuristic_coverage != COVERAGE_FULL, row["id"] + assert result.gaps == [], row["id"]