From 8e7d6323b81866e59e4f2ba57b804daa54065c23 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 19:34:51 +0000 Subject: [PATCH 1/4] feat(script-probe): add unicodedata script-histogram language probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure, thread-safe script classification built on unicodedata only — each letter's Unicode name mentions its script, so token scanning classifies scripts with no hardcoded range tables. Classifies the input's dominant script, a coarse script-derived language guess, and the English-heuristic coverage band (full / partial / none / unknown). A deterministic stride sample bounds probe cost on arbitrarily long input. Co-authored-by: Kalisetti Nihanth Naidu --- inputguard/language.py | 288 +++++++++++++++++++++++++++++++++++++++++ tests/test_language.py | 200 ++++++++++++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 inputguard/language.py create mode 100644 tests/test_language.py diff --git a/inputguard/language.py b/inputguard/language.py new file mode 100644 index 0000000..13be44e --- /dev/null +++ b/inputguard/language.py @@ -0,0 +1,288 @@ +"""Language probe: stdlib-only script detection for honest degradation. + +Every clarity rule in InputGuard is an English-keyword heuristic. On input in +another script (Chinese, Cyrillic, Arabic, ...) none of those keywords match, +so v0.2 silently returned ``ready`` at score 100 for input it had not assessed +at all — the tool asserted a confidence it did not have. + +This module is the v0.3 fix (spec: "Honest degradation for non-English +input"): a pure script histogram built exclusively on :mod:`unicodedata` — +no langdetect, no fastText, no network, honoring the zero-dependency +constraint. Each letter's Unicode *name* mentions its script (``"CJK +UNIFIED IDEOGRAPH-..."``, ``"CYRILLIC SMALL LETTER BE"``, ...), so scanning +name tokens classifies scripts with no hardcoded range tables to rot. + +When the dominant script has no heuristic coverage, ``analyze()`` takes the +explicit degraded path: English-only rules are skipped, a confidence penalty +is applied, and the result carries a ``degradation_note`` — never a silent +``ready``. Scripts with coverage (Latin) behave exactly as they did before +the probe existed. + +The probe is pure and thread-safe: no module-level mutation, only stdlib +lookups, matching the ``analyze()`` thread-safety contract. +""" + +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass +from typing import Dict, Optional + +__all__ = [ + "COVERAGE_FULL", + "COVERAGE_NONE", + "COVERAGE_PARTIAL", + "COVERAGE_UNKNOWN", + "COVERED_SCRIPTS", + "DEGRADATION_PENALTY", + "DEGRADED_INTENT", + "ScriptProbe", + "degradation_note_for", + "partial_coverage_note", + "probe_script", +] + +# Scripts the English keyword heuristics can actually assess. Every rule fires +# on English terms, so Latin script is the only fully covered script today. +COVERED_SCRIPTS = frozenset({"latin"}) + +# Coverage vocabulary for ``AnalysisResult.heuristic_coverage``: +# - "full": >= 70% of classified letters are in covered scripts; rules run +# exactly as before, no note. +# - "partial": 50-70% covered; rules run (the covered majority still drives +# detection), a note flags the uncovered remainder, no penalty. +# - "none": < 50% covered; degraded path — rules skipped, penalty, note. +# - "unknown": no letters at all (digits, punctuation, emoji) — nothing to +# classify; rules run as-is, no note. +COVERAGE_FULL = "full" +COVERAGE_PARTIAL = "partial" +COVERAGE_NONE = "none" +COVERAGE_UNKNOWN = "unknown" + +_FULL_COVERAGE_AT = 0.7 +_NONE_COVERAGE_BELOW = 0.5 + +# Confidence penalty applied on the degraded path (rules skipped). 20 points +# puts a degraded result at score 80: never ``ready`` (85) in either mode — +# ``usable_with_warnings`` in warning mode, ``needs_clarification`` in strict +# mode — so a degraded result can never masquerade as a clean bill of health. +# Policy-tunable in a later release; this constant is the default. +DEGRADATION_PENALTY = 20 + +# Intent reported for degraded results. Intent detection is English-keyword +# based, so on uncovered scripts it has no evidence; "undetermined" is an +# additive intent value that says so instead of guessing the "build" fallback. +DEGRADED_INTENT = "undetermined" + +# The probe examines a deterministic stride sample of at most this many +# characters, bounding probe cost on arbitrarily long input. +_SAMPLE_LIMIT = 4096 + +# Unicode name token -> script label. Name tokens are matched left-to-right, +# so the first script mention in the name wins ("FULLWIDTH LATIN CAPITAL +# LETTER A" -> "latin"; "KATAKANA-HIRAGANA PROLONGED SOUND MARK" -> +# "katakana"). +_SCRIPT_KEYWORDS = { + "latin": "latin", + "cjk": "han", + "ideograph": "han", + "han": "han", + "hiragana": "hiragana", + "katakana": "katakana", + "hangul": "hangul", + "cyrillic": "cyrillic", + "greek": "greek", + "arabic": "arabic", + "hebrew": "hebrew", + "syriac": "syriac", + "thaana": "thaana", + "devanagari": "devanagari", + "bengali": "bengali", + "gurmukhi": "gurmukhi", + "gujarati": "gujarati", + "oriya": "oriya", + "tamil": "tamil", + "telugu": "telugu", + "kannada": "kannada", + "malayalam": "malayalam", + "sinhala": "sinhala", + "thai": "thai", + "lao": "lao", + "tibetan": "tibetan", + "myanmar": "myanmar", + "georgian": "georgian", + "armenian": "armenian", + "ethiopic": "ethiopic", + "cherokee": "cherokee", + "mongolian": "mongolian", + "khmer": "khmer", +} + +# Script label -> coarse language guess. Script-level detection cannot pick +# between languages sharing a script (Han is written as Chinese *and* Japanese +# kanji); the ``ja`` refinement below handles the kana case, and the guess is +# documented as script-derived, never certain. +_SCRIPT_LANGUAGE = { + "latin": "en", + "han": "zh", + "hiragana": "ja", + "katakana": "ja", + "hangul": "ko", + "cyrillic": "ru", + "greek": "el", + "arabic": "ar", + "hebrew": "he", + "syriac": "syc", + "thaana": "dv", + "devanagari": "hi", + "bengali": "bn", + "gurmukhi": "pa", + "gujarati": "gu", + "oriya": "or", + "tamil": "ta", + "telugu": "te", + "kannada": "kn", + "malayalam": "ml", + "sinhala": "si", + "thai": "th", + "lao": "lo", + "tibetan": "bo", + "myanmar": "my", + "georgian": "ka", + "armenian": "hy", + "ethiopic": "am", + "cherokee": "chr", + "mongolian": "mn", + "khmer": "km", +} + + +@dataclass(frozen=True) +class ScriptProbe: + """Result of the script histogram over one input. + + ``heuristic_coverage`` is the field ``analyze()`` branches on; the other + fields feed the degradation note and the result's ``detected_language``. + """ + + detected_language: str + dominant_script: Optional[str] + covered_share: float + heuristic_coverage: str + + +def _sample(text: str, limit: int) -> str: + """Deterministic stride sample of at most ``limit`` characters. + + Striding (not truncating) keeps the histogram representative of the whole + input — a script buried at the end of a long paste is still seen. + """ + if len(text) <= limit: + return text + step = (len(text) + limit - 1) // limit # ceil division + return text[::step] + + +def _script_of(char: str) -> Optional[str]: + """Best-effort script label for one character, or ``None``. + + Non-letters (digits, punctuation, emoji, marks) and unnamed code points + carry no script evidence and return ``None``. + """ + if not unicodedata.category(char).startswith("L"): + return None + try: + name = unicodedata.name(char) + except ValueError: # unassigned / unnamed code point + return None + for token in re.findall(r"[a-z]+", name.lower()): + script = _SCRIPT_KEYWORDS.get(token) + if script is not None: + return script + return None + + +def probe_script(text: str) -> ScriptProbe: + """Classify an input's script coverage with a :mod:`unicodedata` histogram. + + Pure function: same input, same probe, no shared state — safe to call + from parallel ``analyze()`` threads. The classified population is the + 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. + """ + letter_count = 0 + script_counts: Dict[str, int] = {} + for char in _sample(text, _SAMPLE_LIMIT): + if not unicodedata.category(char).startswith("L"): + continue + letter_count += 1 + script = _script_of(char) + if script is not None: + script_counts[script] = script_counts.get(script, 0) + 1 + + classified = sum(script_counts.values()) + if classified == 0: + # Letters with no recognizable script, or no letters at all. + coverage = COVERAGE_NONE if letter_count > 0 else COVERAGE_UNKNOWN + return ScriptProbe( + detected_language="und", + dominant_script=None, + covered_share=0.0, + heuristic_coverage=coverage, + ) + + 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) + + return ScriptProbe( + detected_language=detected_language, + dominant_script=dominant, + covered_share=covered_share, + heuristic_coverage=coverage, + ) + + +def _detected_language(dominant: str, script_counts: Dict[str, int]) -> str: + """Coarse script-derived language guess; "und" when nothing maps.""" + kana_present = script_counts.get("hiragana", 0) + script_counts.get("katakana", 0) + if dominant in ("han", "hiragana", "katakana") and kana_present: + return "ja" # kana alongside han (or alone) reads as Japanese + return _SCRIPT_LANGUAGE.get(dominant, "und") + + +def degradation_note_for(probe: ScriptProbe) -> str: + """The note carried by results on the degraded (uncovered) path.""" + if probe.dominant_script is None: + script_desc = "a script outside the probe's coverage" + else: + script_desc = f"the {probe.dominant_script} script" + return ( + 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 " + "was skipped rather than run with false confidence — this result " + "reports a language limitation of the tool, not a judgment of the " + "input's clarity. Rephrasing the key details in English enables a " + "full analysis." + ) + + +def partial_coverage_note(probe: ScriptProbe) -> str: + """The note carried when rules ran but a minority script is uncovered.""" + covered_pct = round(probe.covered_share * 100) + return ( + f"Input mixes scripts — about {covered_pct}% of its letters fall " + "within the English-heuristic coverage. Rules ran on the input as a " + "whole, so findings may miss content in the uncovered script(s)." + ) diff --git a/tests/test_language.py b/tests/test_language.py new file mode 100644 index 0000000..a66d628 --- /dev/null +++ b/tests/test_language.py @@ -0,0 +1,200 @@ +"""Tests for the language probe (unicodedata script histogram). + +Covers the script classification and coverage-band logic of +``inputguard.language``. End-to-end degradation behavior (the degraded +``analyze()`` path) is covered in the integration section appended by the +non-English-honesty commit. +""" + +from __future__ import annotations + +import unicodedata + +from inputguard.language import ( + COVERAGE_FULL, + COVERAGE_NONE, + COVERAGE_PARTIAL, + COVERAGE_UNKNOWN, + DEGRADATION_PENALTY, + DEGRADED_INTENT, + _sample, + degradation_note_for, + partial_coverage_note, + probe_script, +) + +# Well-known probe-P2 input: pure Chinese, zero English heuristic coverage. +CHINESE = "建造一个用户登录应用" + + +def test_pure_english_is_full_coverage_en(): + probe = probe_script("build a rest api with users in postgresql") + assert probe.dominant_script == "latin" + assert probe.detected_language == "en" + assert probe.heuristic_coverage == COVERAGE_FULL + assert probe.covered_share == 1.0 + + +def test_pure_chinese_is_uncovered_han(): + probe = probe_script(CHINESE) + assert probe.dominant_script == "han" + assert probe.detected_language == "zh" + assert probe.heuristic_coverage == COVERAGE_NONE + assert probe.covered_share == 0.0 + + +def test_cyrillic_is_uncovered(): + probe = probe_script("почему моя программа не работает") + assert probe.dominant_script == "cyrillic" + assert probe.detected_language == "ru" + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_arabic_is_uncovered(): + probe = probe_script("لماذا لا يعمل هذا الكود") + assert probe.dominant_script == "arabic" + assert probe.detected_language == "ar" + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_hebrew_is_uncovered(): + probe = probe_script("למה הקוד הזה לא עובד") + assert probe.dominant_script == "hebrew" + assert probe.detected_language == "he" + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_greek_is_uncovered(): + probe = probe_script("γιατί δεν λειτουργεί") + assert probe.dominant_script == "greek" + assert probe.detected_language == "el" + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_korean_is_uncovered(): + probe = probe_script("이 코드가 왜 작동하지 않는 건가요") + assert probe.dominant_script == "hangul" + assert probe.detected_language == "ko" + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_japanese_kana_reads_as_ja(): + probe = probe_script("このコードが動作しないのはなぜですか") + assert probe.dominant_script in ("hiragana", "katakana", "han") + assert probe.detected_language == "ja" + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_fullwidth_latin_reads_as_latin(): + # Fullwidth Latin: the Unicode name's first token ("FULLWIDTH") is not + # the script; the token scan must find "latin". + probe = probe_script("API") + assert probe.dominant_script == "latin" + assert probe.heuristic_coverage == COVERAGE_FULL + + +def test_fullwidth_latin_mixed_with_japanese_is_partial(): + # 3 fullwidth latin letters vs 2 japanese letters -> exactly 0.5 share, + # the partial band boundary: rules still run, a note flags the rest. + probe = probe_script("APIを作る") + assert probe.dominant_script == "latin" + assert probe.heuristic_coverage == COVERAGE_PARTIAL + + +def test_digits_and_punctuation_have_no_coverage_signal(): + probe = probe_script("12345 !!! ??? ...") + assert probe.dominant_script is None + assert probe.detected_language == "und" + assert probe.heuristic_coverage == COVERAGE_UNKNOWN + + +def test_majority_english_with_minority_han_is_full(): + # 12 latin letters vs 2 han letters -> covered share well above 0.7. + probe = probe_script("make it faster 这个") + assert probe.dominant_script == "latin" + assert probe.heuristic_coverage == COVERAGE_FULL + + +def test_partial_band_input_is_between_full_and_none(): + # 5 latin letters vs 4 han letters -> covered share 5/9 (~0.56), inside + # the partial band (>= 0.5, < 0.7). + probe = probe_script("abcde 这是测试") + assert probe.heuristic_coverage == COVERAGE_PARTIAL + + +def test_unknown_script_letters_take_none_path(): + # Runic letters: real letters whose Unicode names the keyword table does + # not map — the honest classification is "no coverage", not a silent pass. + probe = probe_script("ᚠᚢᚦᚨᚱᚲ") + assert probe.dominant_script is None + assert probe.heuristic_coverage == COVERAGE_NONE + + +def test_probe_is_deterministic(): + text = "mixed 混合 input テキスト with 数字 123 and émojis 🏳️‍🌈" + assert probe_script(text) == probe_script(text) + + +def test_sample_bounds_length_and_is_deterministic(): + text = "a" * 100_000 + sample = _sample(text, 4096) + assert len(sample) <= 4096 + assert sample == _sample(text, 4096) + assert _sample("short", 4096) == "short" + + +def test_sample_lets_tail_scripts_surface(): + # A buried script at the very end of a huge paste must still be seen by + # the stride sample (stride 3 over 10 000 chars hits index 9999). + text = "a" * 9999 + "中" + probe = probe_script(text) + assert probe.dominant_script == "latin" + assert probe.covered_share < 1.0 + + +def test_degradation_penalty_never_allows_ready(): + # 100 - penalty must stay below the 85 ready floor in both modes. + assert 100 - DEGRADATION_PENALTY < 85 + + +def test_degraded_intent_value_is_undetermined(): + assert DEGRADED_INTENT == "undetermined" + + +def test_note_for_dominant_script_mentions_script_and_language(): + probe = probe_script(CHINESE) + note = degradation_note_for(probe) + assert "han" in note + assert "zh" in note + assert "English" in note + + +def test_note_for_unrecognized_script_names_the_limitation(): + probe = probe_script("ᚠᚢᚦᚨᚱᚲ") + note = degradation_note_for(probe) + assert "outside the probe's coverage" in note + + +def test_partial_note_reports_coverage_percentage(): + probe = probe_script("abcde 这是测试") + note = partial_coverage_note(probe) + assert "uncovered" in note + + +def test_script_probe_is_frozen(): + probe = probe_script(CHINESE) + try: + probe.detected_language = "en" + raised = False + except Exception: + raised = True + assert raised + + +def test_non_letters_are_skipped_by_category(): + # Digits, punctuation, emoji, combining marks, and control characters + # contribute nothing to the histogram — only letters classify. + for ch in "0九!!。🏳️‍🌈́\u0000": + assert unicodedata.category(ch) # sanity: all real code points + probe = probe_script("... 123 🏳️‍🌈") + assert probe.heuristic_coverage == COVERAGE_UNKNOWN From e3193ec68400fdcecfa5813d66b4a2381ac12535 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 19:37:30 +0000 Subject: [PATCH 2/4] feat(degraded-path): explicit degradation for scripts without heuristic coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the script probe into analyze(): uncovered scripts skip the English-only rules outright, take a 20-point confidence penalty (score 80 — never 'ready' in either mode), and report detected_intent 'undetermined' instead of an unearned fallback. Results carry the three additive fields detected_language, heuristic_coverage, and degradation_note; to_dict() includes them additively. Covered scripts (full/partial coverage) run the unchanged pipeline — partial adds a note without a penalty. Co-authored-by: Kalisetti Nihanth Naidu --- CHANGELOG.md | 14 ++++++++++++++ README.md | 29 +++++++++++++++++++++++++++++ inputguard/analyzer.py | 41 ++++++++++++++++++++++++++++++++++++++++- inputguard/types.py | 10 ++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9910fbd..4879789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [Unreleased] +### Added +- Multilingual degradation: a zero-dependency script probe + (`unicodedata`-based histogram, `inputguard/language.py`) classifies + each input's script before rules run. Scripts without English + heuristic coverage take an explicit degraded path — rules are skipped, + a 20-point confidence penalty applies, and the result carries + `detected_language`, `heuristic_coverage`, and `degradation_note` + instead of silently scoring 100/ready. +- Additive `AnalysisResult` fields: `detected_language`, + `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. + ## [0.2.0] — 2026-05-29 ### Added - Auto intent detection. `.analyze()` now detects whether the input diff --git a/README.md b/README.md index 7bc578b..8c4d206 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,35 @@ Use `warning` when you want to surface gaps to the user without blocking. Use `s --- +## Non-English input + +InputGuard's rules are English-language heuristics. Before any rule runs, a zero-dependency script probe (stdlib `unicodedata` only) classifies the input's script. When the script has no heuristic coverage, InputGuard says so instead of pretending: + +```python +result = guard.analyze("建造一个用户登录应用") + +result.status # 'usable_with_warnings' — never 'ready' +result.clarity_score # 80 (100 minus the degradation penalty) +result.detected_intent # 'undetermined' +result.detected_language # 'zh' (coarse, script-derived guess) +result.heuristic_coverage # 'none' +result.degradation_note # explains that rules were skipped and why +``` + +The rules are **skipped explicitly** on uncovered scripts — running English keyword rules on text they cannot read would produce a silent, unearned verdict. A degraded result is never `ready` in either mode (strict mode returns `needs_clarification`). In v0.2 this input silently scored 100/ready; v0.3 refuses to assert a confidence it does not have. + +Three additive fields on the result carry the probe's verdict: + +| Field | Values | +|---|---| +| `detected_language` | coarse script-derived guess (`'en'`, `'zh'`, `'ja'`, `'ko'`, `'ru'`, `'ar'`, ...; `'und'` when unclassifiable) | +| `heuristic_coverage` | `'full'` (≥ 70% of letters covered — rules run exactly as before), `'partial'` (50–70% — rules run, note flags the uncovered remainder), `'none'` (degraded path), `'unknown'` (no letters to classify) | +| `degradation_note` | `None`, or an explanation of what was skipped and why | + +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. + +--- + ## How intent detection works InputGuard automatically detects what kind of coding input it is receiving. No extra parameters needed. The same `.analyze()` call handles all five intent types. diff --git a/inputguard/analyzer.py b/inputguard/analyzer.py index 23900f9..94074ca 100644 --- a/inputguard/analyzer.py +++ b/inputguard/analyzer.py @@ -5,6 +5,15 @@ import inputguard.rules # noqa: F401 — importing registers the coding domain and the 19 built-in rules from inputguard.detector import detect_intent, normalize from inputguard.followups import get_follow_ups +from inputguard.language import ( + COVERAGE_NONE, + COVERAGE_PARTIAL, + DEGRADATION_PENALTY, + DEGRADED_INTENT, + degradation_note_for, + partial_coverage_note, + probe_script, +) from inputguard.recommender import get_recommendations from inputguard.registry import REGISTRY from inputguard.scorer import calculate_score, get_status @@ -36,9 +45,32 @@ def analyze(self, user_input: str, domain: str = "coding") -> AnalysisResult: raise ValueError("user_input must be a non-empty, non-whitespace string.") domain_signals = REGISTRY.get_domain_signals(domain) + normalized = normalize(user_input) + + # Language probe (pipeline step 2): classify the input's script before + # any rule runs, so scripts outside heuristic coverage take the + # explicit degradation path instead of silently passing as ready. + probe = probe_script(normalized) + if probe.heuristic_coverage == COVERAGE_NONE: + # English-only rules are skipped outright — running them on a + # script they cannot read would produce a silent, unearned + # ready. The penalty keeps the result out of "ready" in both + # modes; the note says honestly what the tool does not know. + score = max(0, 100 - DEGRADATION_PENALTY) + return AnalysisResult( + status=get_status(score, self.mode), + clarity_score=score, + detected_intent=DEGRADED_INTENT, + gaps=[], + recommendations=[], + findings=[], + interpretation_note=None, + detected_language=probe.detected_language, + heuristic_coverage=probe.heuristic_coverage, + degradation_note=degradation_note_for(probe), + ) detected_intent = detect_intent(user_input, domain_signals) - normalized = normalize(user_input) findings: List[RuleFinding] = [] seen_codes: Set[str] = set() @@ -78,4 +110,11 @@ def analyze(self, user_input: str, domain: str = "coding") -> AnalysisResult: findings=findings, interpretation_note=interpretation_note, follow_ups=follow_ups, + detected_language=probe.detected_language, + heuristic_coverage=probe.heuristic_coverage, + degradation_note=( + partial_coverage_note(probe) + if probe.heuristic_coverage == COVERAGE_PARTIAL + else None + ), ) diff --git a/inputguard/types.py b/inputguard/types.py index 8dcdd5c..573046b 100644 --- a/inputguard/types.py +++ b/inputguard/types.py @@ -25,6 +25,13 @@ class AnalysisResult: # deduped and ordered with `gaps`. Appended after the v0.2 fields so any # positional construction keeps its meaning. follow_ups: List[str] = field(default_factory=list) + # v0.3 multilingual degradation (additive, see inputguard/language.py): + # the script probe's verdict. ``heuristic_coverage`` is "full", "partial", + # "none", or "unknown"; a non-None ``degradation_note`` marks results the + # rules could not fully assess. + detected_language: str = "en" + heuristic_coverage: str = "full" + degradation_note: Optional[str] = None def to_dict(self) -> dict: return { @@ -36,6 +43,9 @@ def to_dict(self) -> dict: "follow_ups": list(self.follow_ups), "findings": [asdict(f) for f in self.findings], "interpretation_note": self.interpretation_note, + "detected_language": self.detected_language, + "heuristic_coverage": self.heuristic_coverage, + "degradation_note": self.degradation_note, } def is_clear(self) -> bool: From 1c5ba877ff765d1c5151038b7e9407b655d9cde6 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 19:41:36 +0000 Subject: [PATCH 3/4] test(non-english-honesty): probe-P2 regression, Unicode samples, thread parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe-P2 input (Chinese) must return a degraded result with a degradation_note — never ready/100. Also covers: degraded shape and mode behavior, per-script degradation, English parity (probe fields on covered input, byte-parity scores: 'fix my code' -> 35, repeated build -> 50), partial-coverage rules-run-with-note, validation order, additive to_dict() keys, systematic no-crash Unicode samples with the note-consistency invariant, bounded long-input probing, and 64-worker parallel analyze() consistency across English/Chinese inputs. Co-authored-by: Kalisetti Nihanth Naidu --- tests/test_language.py | 231 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 225 insertions(+), 6 deletions(-) diff --git a/tests/test_language.py b/tests/test_language.py index a66d628..685515a 100644 --- a/tests/test_language.py +++ b/tests/test_language.py @@ -1,15 +1,22 @@ -"""Tests for the language probe (unicodedata script histogram). - -Covers the script classification and coverage-band logic of -``inputguard.language``. End-to-end degradation behavior (the degraded -``analyze()`` path) is covered in the integration section appended by the -non-English-honesty commit. +"""Tests for the language probe and the multilingual degradation path. + +Probe units cover script classification and coverage bands +(``inputguard.language``). The integration section covers the degraded +``analyze()`` path end to end: the probe-P2 regression (Chinese input +must never silently return ready/100), mode behavior, additive result +fields, English parity, systematic Unicode no-crash samples, and +thread-safe parallel analysis. """ from __future__ import annotations +import json import unicodedata +from concurrent.futures import ThreadPoolExecutor + +import pytest +from inputguard import InputGuard from inputguard.language import ( COVERAGE_FULL, COVERAGE_NONE, @@ -198,3 +205,215 @@ def test_non_letters_are_skipped_by_category(): assert unicodedata.category(ch) # sanity: all real code points probe = probe_script("... 123 🏳️‍🌈") assert probe.heuristic_coverage == COVERAGE_UNKNOWN + + +# --------------------------------------------------------------------------- +# Integration: the degraded analyze() path (non-English honesty) +# --------------------------------------------------------------------------- + +# Probe P2's exact input: v0.2 returned intent=build, score 100, ready, +# zero findings — a silent pass on input the rules cannot read. +PROBE_P2_INPUT = "建造一个用户登录应用" + + +def test_probe_p2_regression_chinese_never_silent_ready(): + guard = InputGuard() + r = guard.analyze(PROBE_P2_INPUT) + assert r.heuristic_coverage == COVERAGE_NONE + assert r.degradation_note is not None + assert r.status != "ready" + assert r.clarity_score != 100 + assert not r.is_clear() + + +def test_degraded_result_shape(): + r = InputGuard().analyze(PROBE_P2_INPUT) + assert r.clarity_score == 100 - DEGRADATION_PENALTY + assert r.detected_intent == DEGRADED_INTENT + assert r.detected_language == "zh" + assert r.gaps == [] + assert r.findings == [] + assert r.recommendations == [] + assert r.interpretation_note is None + + +def test_degraded_strict_mode_returns_needs_clarification_not_blocked(): + r = InputGuard(mode="strict").analyze(PROBE_P2_INPUT) + # Score 80 lands in the strict clarify band (65-84), below the ready + # floor in both modes. + assert r.status == "needs_clarification" + + +def test_degraded_warning_mode_returns_usable_with_warnings(): + r = InputGuard().analyze(PROBE_P2_INPUT) + assert r.status == "usable_with_warnings" + + +def test_degradation_note_is_actionable(): + r = InputGuard().analyze(PROBE_P2_INPUT) + assert "English" in r.degradation_note + assert "han" in r.degradation_note + + +def test_degraded_applies_to_every_uncovered_script(): + for text in ( + "почему моя программа не работает", + "لماذا لا يعمل هذا الكود", + "이 코드가 왜 작동하지 않나요", + "このコードが動作しないのはなぜですか", + "γιατί δεν λειτουργεί", + "ᚠᚢᚦᚨᚱᚲ", + ): + r = InputGuard().analyze(text) + assert r.heuristic_coverage == COVERAGE_NONE, text + assert r.degradation_note is not None, text + assert r.status != "ready", text + + +def test_english_parity_probe_fields(): + # Covered input carries the probe's verdict but no degradation. The + # clarity verdict itself is unchanged v0.2 behavior: one distinct gap + # (api structure) -> 100 - 25 = 75. + r = InputGuard().analyze("Build a REST API using FastAPI. Store users in PostgreSQL.") + assert r.detected_language == "en" + assert r.heuristic_coverage == COVERAGE_FULL + assert r.degradation_note is None + assert r.status == "usable_with_warnings" + assert r.clarity_score == 75 + + +def test_english_parity_score_unchanged(): + # Byte-parity with the pre-probe pipeline on a vague English input: + # debug intent, three distinct gaps -> 100 - 25 - 25 - 15 = 35 (the + # score the v0.2 README documents for this input). + r = InputGuard().analyze("fix my code") + assert r.detected_intent == "debug" + assert r.clarity_score == 35 + assert r.status == "needs_clarification" + assert r.heuristic_coverage == COVERAGE_FULL + assert r.degradation_note is None + + +def test_partial_coverage_runs_rules_with_note_no_penalty(): + # ~56% covered share: rules run (findings computed as usual), the note + # flags the uncovered remainder, and no degradation penalty applies. + r = InputGuard().analyze("abcde 这是测试") + assert r.heuristic_coverage == COVERAGE_PARTIAL + assert r.degradation_note is not None + assert r.clarity_score == 100 # 5-letter input fires no rules + + +def test_validation_contracts_precede_the_probe(): + guard = InputGuard() + with pytest.raises(TypeError): + guard.analyze(12345) # type: ignore[arg-type] + with pytest.raises(ValueError): + guard.analyze(" ") + # Unknown domain raises even for input the probe would degrade. + with pytest.raises(ValueError): + guard.analyze(PROBE_P2_INPUT, domain="legal") + + +def test_to_dict_includes_additive_fields(): + d = InputGuard().analyze(PROBE_P2_INPUT).to_dict() + assert d["detected_language"] == "zh" + assert d["heuristic_coverage"] == "none" + assert isinstance(d["degradation_note"], str) + # v0.2 keys remain byte-identical in name and order. + assert list(d)[:7] == [ + "status", + "clarity_score", + "detected_intent", + "gaps", + "recommendations", + "findings", + "interpretation_note", + ] + json.dumps(d, ensure_ascii=False) # JSON-serializable as before + + +# --------------------------------------------------------------------------- +# Property-style no-crash on arbitrary Unicode (systematic samples — +# hypothesis is not a dev dependency; zero-dep constraint honored) +# --------------------------------------------------------------------------- + +_VALID_STATUSES = {"ready", "usable_with_warnings", "needs_clarification", "blocked"} + +_UNICODE_SAMPLES = [ + "emoji only 🚀🔥🏳️‍🌈", + "mixed 混合 text وبالعربية معا", + "zero width zero​width joiner", + "rtl override ‮reverse‭", + "combining marks é̈ 👨‍👩‍👧‍👦", + "unassigned \u0378 codepoint", + "cjk mixed with ascii: build api 用户", + "fullwidth letters 123", + "tab\tand\nnewline\r\nmixes", + "ʼn concatenations ʻʼʽ", + "íàéä ççñň ņņň — diacritic soup", + "文字化け mojibake", + "سيب ذلك mixed rtl ltr", + "҈ all the combining things ✈ ✈ ✈", +] + + +@pytest.mark.parametrize("text", _UNICODE_SAMPLES) +def test_no_crash_valid_result_on_arbitrary_unicode(text): + r = InputGuard().analyze(text) + assert r.status in _VALID_STATUSES + assert 0 <= r.clarity_score <= 100 + # A note exists exactly when the probe could not fully cover the input. + if r.heuristic_coverage in (COVERAGE_FULL, COVERAGE_UNKNOWN): + assert r.degradation_note is None + else: + assert r.degradation_note is not None + if r.heuristic_coverage == COVERAGE_NONE: + # Degraded path contract: rules skipped, honest intent, never ready. + assert r.findings == [] + assert r.detected_intent == DEGRADED_INTENT + assert r.status != "ready" + + +def test_long_input_bounded_probe_still_classifies(): + # ~100k chars: the stride sample keeps the probe bounded while still + # classifying correctly. The clarity verdict is unchanged v0.2 + # behavior for the repeated build request (missing language + api + # structure -> 100 - 25 - 25 = 50). + r = InputGuard().analyze("build a rest api " * 6000) + assert r.status == "needs_clarification" + assert r.clarity_score == 50 + assert r.heuristic_coverage == COVERAGE_FULL + assert r.degradation_note is None + + +def test_parallel_analyze_thread_safety_64_workers(): + # 64 workers over mixed English/Chinese inputs must agree with the + # single-threaded results (the v0.2 thread-safety contract, rerun with + # the probe in the pipeline). + guard = InputGuard() + strict = InputGuard(mode="strict") + inputs = [ + "fix my code", + PROBE_P2_INPUT, + "Build a REST API using FastAPI. Store users in PostgreSQL.", + "почему моя программа не работает", + "make it faster 这个", + ] + expected = {text: guard.analyze(text).to_dict() for text in inputs} + expected_strict = {PROBE_P2_INPUT: strict.analyze(PROBE_P2_INPUT).to_dict()} + + def run(pair): + text, mode = pair + target = strict if mode == "strict" else guard + return text, mode, target.analyze(text).to_dict() + + jobs = [(text, "warning") for text in inputs for _ in range(12)] + [ + (PROBE_P2_INPUT, "strict") for _ in range(4) + ] + assert len(jobs) == 64 + with ThreadPoolExecutor(max_workers=16) as pool: + results = list(pool.map(run, jobs)) + + for text, mode, result in results: + baseline = expected_strict if mode == "strict" else expected + assert result == baseline[text] From d2a4eb50aea95318acdea16d69b26a852e3c09a2 Mon Sep 17 00:00:00 2001 From: Obvious Date: Thu, 17 Sep 2026 19:45:47 +0000 Subject: [PATCH 4/4] fix(tests): adapt to_dict key-set expectations to the merged additive contract PR #4's parity test asserted the exact to_dict key set with follow_ups as the only additive key; the three language-probe fields are additive per the v0.3 spec, so the expectation now covers the merged additive set. The key-order test documents the merged 11-key contract. Co-authored-by: Kalisetti Nihanth Naidu --- tests/test_followups.py | 11 +++++++++-- tests/test_language.py | 10 ++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/test_followups.py b/tests/test_followups.py index fd74b0e..6e21a5d 100644 --- a/tests/test_followups.py +++ b/tests/test_followups.py @@ -282,7 +282,9 @@ def _ordered_unique_gaps(findings): def test_english_score_parity_with_v02(text): # follow_ups are derived from gaps after scoring; the v0.2 numbers must # not move: same findings, same gap order, same score as the legacy - # runners, and to_dict() gains exactly one additive key. + # runners, and to_dict() gains exactly the merged additive keys + # (follow_ups from the questions engine; the language-probe fields from + # the multilingual degradation work). result = InputGuard().analyze(text) legacy = _V02_INTENT_RUNNERS[result.detected_intent](text) @@ -291,7 +293,12 @@ def test_english_score_parity_with_v02(text): assert result.clarity_score == calculate_score(legacy) d = result.to_dict() - assert set(d) == _V02_TO_DICT_KEYS | {"follow_ups"} + assert set(d) == _V02_TO_DICT_KEYS | { + "follow_ups", + "detected_language", + "heuristic_coverage", + "degradation_note", + } v02_shaped = AnalysisResult( status=result.status, clarity_score=result.clarity_score, diff --git a/tests/test_language.py b/tests/test_language.py index 685515a..d235d0f 100644 --- a/tests/test_language.py +++ b/tests/test_language.py @@ -319,15 +319,21 @@ def test_to_dict_includes_additive_fields(): assert d["detected_language"] == "zh" assert d["heuristic_coverage"] == "none" assert isinstance(d["degradation_note"], str) - # v0.2 keys remain byte-identical in name and order. - assert list(d)[:7] == [ + # Merged additive contract: v0.2 keys keep their names and relative + # order, follow_ups slots in after recommendations (questions engine), + # and the three probe keys trail. + assert list(d) == [ "status", "clarity_score", "detected_intent", "gaps", "recommendations", + "follow_ups", "findings", "interpretation_note", + "detected_language", + "heuristic_coverage", + "degradation_note", ] json.dumps(d, ensure_ascii=False) # JSON-serializable as before