Spaces:
Sleeping
Sleeping
Claude
test: réorganiser les 110 fichiers tests/test_*.py par cercle architectural
d109222 unverified | """Tests Sprint 71 — A.I.1 chantier 2 : rare-token recall. | |
| Couvre : | |
| 1. ``tokenize`` : Unicode-aware, contractions (l'an, d'une), | |
| composés (peut-être, c'est-à -dire), apostrophe typographique | |
| ’, vide / None. | |
| 2. ``frequency_distribution`` : comptage corpus-wide, casse | |
| insensible par défaut, multi-doc. | |
| 3. ``extract_rare_tokens`` : hapax (max_freq=1), dis legomena | |
| (max_freq=2), ``max_freq < 1`` → ValueError. | |
| 4. ``compute_rare_token_recall`` : | |
| - cas standard : 5 rares en GT, 4 préservés | |
| - multiplicité : un rare présent 2× en GT, 1× en hyp → 0.5 | |
| - hyp vide → 0.0, tous manqués | |
| - GT sans rare → 0.0, listes vides | |
| - case_sensitive | |
| 5. ``rare_token_recall`` raccourci. | |
| 6. **Cas réaliste** : registre d'état civil, noms propres rares | |
| discriminés. | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| from picarones.measurements.rare_tokens import ( | |
| compute_rare_token_recall, | |
| extract_rare_tokens, | |
| frequency_distribution, | |
| rare_token_recall, | |
| tokenize, | |
| ) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 1. tokenize | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestTokenize: | |
| def test_basic_words(self) -> None: | |
| assert tokenize("hello world") == ["hello", "world"] | |
| def test_contraction_apostrophe_ascii(self) -> None: | |
| # L'an est un seul token | |
| assert tokenize("L'an") == ["L'an"] | |
| assert tokenize("d'une chose") == ["d'une", "chose"] | |
| def test_contraction_apostrophe_typographic(self) -> None: | |
| # ’ (U+2019) traité comme ' à l'intérieur du token | |
| assert tokenize("d’une") == ["d’une"] | |
| def test_compound_with_hyphen(self) -> None: | |
| assert tokenize("peut-être") == ["peut-être"] | |
| assert tokenize("c'est-Ã -dire") == ["c'est-Ã -dire"] | |
| def test_unicode_diacritics(self) -> None: | |
| assert tokenize("café à é ô") == ["café", "à ", "é", "ô"] | |
| def test_punctuation_separates(self) -> None: | |
| assert tokenize("Marie, fille.") == ["Marie", "fille"] | |
| def test_numbers_are_tokens(self) -> None: | |
| assert tokenize("en 1789 et 1790") == ["en", "1789", "et", "1790"] | |
| def test_empty_input(self) -> None: | |
| assert tokenize("") == [] | |
| assert tokenize(None) == [] | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 2. frequency_distribution | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestFrequencyDistribution: | |
| def test_single_document(self) -> None: | |
| freq = frequency_distribution(["hello hello world"]) | |
| assert freq["hello"] == 2 | |
| assert freq["world"] == 1 | |
| def test_multi_document_summed(self) -> None: | |
| docs = ["hello world", "hello sun", "moon"] | |
| freq = frequency_distribution(docs) | |
| assert freq["hello"] == 2 | |
| assert freq["world"] == 1 | |
| assert freq["moon"] == 1 | |
| def test_case_insensitive_default(self) -> None: | |
| freq = frequency_distribution(["Hello hello HELLO"]) | |
| assert freq["hello"] == 3 | |
| assert "Hello" not in freq | |
| def test_case_sensitive(self) -> None: | |
| freq = frequency_distribution( | |
| ["Hello hello"], case_sensitive=True, | |
| ) | |
| assert freq["Hello"] == 1 | |
| assert freq["hello"] == 1 | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 3. extract_rare_tokens | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestExtractRareTokens: | |
| def test_hapax_only(self) -> None: | |
| # max_freq=1 → uniquement les tokens uniques | |
| docs = ["a a b c"] | |
| rare = extract_rare_tokens(docs, max_freq=1) | |
| assert rare == frozenset({"b", "c"}) | |
| def test_hapax_plus_dis_legomena_default(self) -> None: | |
| # max_freq=2 par défaut | |
| docs = ["a a a b b c"] | |
| rare = extract_rare_tokens(docs) | |
| # a (3) écarté, b (2) inclus, c (1) inclus | |
| assert rare == frozenset({"b", "c"}) | |
| def test_invalid_max_freq(self) -> None: | |
| with pytest.raises(ValueError): | |
| extract_rare_tokens(["x"], max_freq=0) | |
| with pytest.raises(ValueError): | |
| extract_rare_tokens(["x"], max_freq=-1) | |
| def test_empty_corpus(self) -> None: | |
| assert extract_rare_tokens([]) == frozenset() | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 4. compute_rare_token_recall | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestComputeRareTokenRecall: | |
| def test_full_recall(self) -> None: | |
| rare = {"alice", "bob"} | |
| m = compute_rare_token_recall( | |
| "alice et bob mangent", "alice et bob mangent", rare, | |
| ) | |
| assert m["recall"] == 1.0 | |
| assert m["n_rare_tokens_in_reference"] == 2 | |
| assert m["n_rare_tokens_recalled"] == 2 | |
| assert m["missed_tokens"] == [] | |
| def test_partial_recall(self) -> None: | |
| rare = {"alice", "bob", "charlie"} | |
| m = compute_rare_token_recall( | |
| "alice bob charlie", "alice bob", rare, | |
| ) | |
| assert m["n_rare_tokens_in_reference"] == 3 | |
| assert m["n_rare_tokens_recalled"] == 2 | |
| assert m["recall"] == pytest.approx(2 / 3) | |
| assert m["missed_tokens"] == ["charlie"] | |
| def test_zero_recall(self) -> None: | |
| rare = {"alice", "bob"} | |
| m = compute_rare_token_recall( | |
| "alice bob", "x y z", rare, | |
| ) | |
| assert m["recall"] == 0.0 | |
| assert sorted(m["missed_tokens"]) == ["alice", "bob"] | |
| def test_multiplicity(self) -> None: | |
| # Un token rare présent 2 fois en GT, 1 fois en hyp → 0.5 | |
| rare = {"dupont"} | |
| m = compute_rare_token_recall( | |
| "Dupont et Dupont sont là ", "Dupont arrive", rare, | |
| ) | |
| assert m["n_rare_tokens_in_reference"] == 2 | |
| assert m["n_rare_tokens_recalled"] == 1 | |
| assert m["recall"] == 0.5 | |
| assert m["missed_tokens"] == ["dupont"] | |
| def test_no_rare_in_gt(self) -> None: | |
| rare = {"alice"} | |
| m = compute_rare_token_recall("hello world", "hello world", rare) | |
| assert m["n_rare_tokens_in_reference"] == 0 | |
| assert m["recall"] == 0.0 | |
| assert m["missed_tokens"] == [] | |
| def test_empty_hyp(self) -> None: | |
| rare = {"alice", "bob"} | |
| m = compute_rare_token_recall("alice bob", "", rare) | |
| assert m["recall"] == 0.0 | |
| assert sorted(m["missed_tokens"]) == ["alice", "bob"] | |
| def test_none_inputs(self) -> None: | |
| rare = {"alice"} | |
| m = compute_rare_token_recall(None, None, rare) | |
| assert m["recall"] == 0.0 | |
| assert m["n_rare_tokens_in_reference"] == 0 | |
| def test_case_insensitive_default(self) -> None: | |
| rare = {"Alice"} # passé en casse mixte | |
| m = compute_rare_token_recall("alice arrive", "alice", rare) | |
| # Casse-insensible par défaut : "Alice" → "alice", match | |
| assert m["recall"] == 1.0 | |
| def test_case_sensitive(self) -> None: | |
| rare = {"Alice"} | |
| m = compute_rare_token_recall( | |
| "Alice arrive", "alice arrive", rare, | |
| case_sensitive=True, | |
| ) | |
| # GT contient "Alice", hyp contient "alice" → pas de match | |
| # parce qu'on est sensible à la casse | |
| assert m["n_rare_tokens_in_reference"] == 1 | |
| assert m["recall"] == 0.0 | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 5. Raccourci | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestShortcut: | |
| def test_shortcut_matches_full(self) -> None: | |
| rare = {"alice", "bob"} | |
| full = compute_rare_token_recall("alice bob", "alice", rare) | |
| assert rare_token_recall( | |
| "alice bob", "alice", rare, | |
| ) == pytest.approx(full["recall"]) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # 6. Cas réaliste : registre d'état civil | |
| # ────────────────────────────────────────────────────────────────────────── | |
| class TestRealisticCivilRecord: | |
| def test_proper_nouns_discrimination(self) -> None: | |
| # 3 actes d'état civil avec noms propres uniques | |
| corpus = [ | |
| "Marie Dupont, fille de Jean Dupont, baptisée 1789.", | |
| "Pierre Durand, fils de Catherine Bernard, né 1790.", | |
| "Jacques Martin, époux de Anne Lefèvre, décédé 1801.", | |
| ] | |
| rare = extract_rare_tokens(corpus, max_freq=2) | |
| # Tous les noms propres sont hapax (1 occurrence) sauf | |
| # « Dupont » (2 occurrences = dis legomenon). Tous restent | |
| # « rares » avec max_freq=2. | |
| assert "dupont" in rare | |
| assert "lefèvre" in rare | |
| assert "martin" in rare | |
| # OCR fautif qui rate les noms propres mais préserve les | |
| # mots fréquents | |
| gt = corpus[0] | |
| hyp_bad_proper = "Marie X, fille de Jean X, baptisée 1789." | |
| m = compute_rare_token_recall(gt, hyp_bad_proper, rare) | |
| # « Dupont » présent 2 fois en GT, 0 fois en hyp → 0/2 | |
| # « Marie » et autres mots non rares → ignorés | |
| # « 1789 » est rare, présent 1 fois en GT, 1 fois en hyp → 1/1 | |
| # « baptisée » est rare aussi | |
| assert m["n_rare_tokens_recalled"] < m["n_rare_tokens_in_reference"] | |
| # Au moins « dupont » manqué | |
| assert "dupont" in m["missed_tokens"] | |
| def test_proper_ocr_discriminates_more_than_cer(self) -> None: | |
| """Vérifie la conjecture du plan : un OCR qui préserve la | |
| structure mais rate les noms propres a un CER faible mais | |
| un rare-token recall plus dégradé. | |
| On compare deux OCR sur le même GT : | |
| - OCR_A : rate un nom propre rare (« Dupont ») | |
| - OCR_B : rate un mot fréquent (« le » présent ≥ 3× dans | |
| le corpus, donc PAS dans le set des rares) | |
| """ | |
| # Corpus suffisamment grand pour que « le » soit fréquent | |
| # (≥ 3 occurrences) et donc non-rare. | |
| corpus = [ | |
| "Marie Dupont arriva le matin chez le notaire.", | |
| "Pierre Durand le suivit dans le couloir.", | |
| "Catherine Bernard attendait le retour le soir.", | |
| "Jacques Martin écouta le récit de la journée.", | |
| ] | |
| rare = extract_rare_tokens(corpus, max_freq=2) | |
| # Sanité : « le » n'est PAS rare (apparaît 7 fois) | |
| assert "le" not in rare | |
| # « Dupont » est rare (1 occurrence) | |
| assert "dupont" in rare | |
| gt = corpus[0] | |
| hyp_a_proper_lost = "Marie X arriva le matin chez le notaire." | |
| hyp_b_freq_lost = "Marie Dupont arriva matin chez notaire." # 2 « le » manquent | |
| m_a = compute_rare_token_recall(gt, hyp_a_proper_lost, rare) | |
| m_b = compute_rare_token_recall(gt, hyp_b_freq_lost, rare) | |
| # OCR_A perd un rare (« Dupont »), OCR_B n'en perd aucun | |
| # (« le » n'est pas rare donc sa perte n'affecte pas le recall) | |
| assert m_a["recall"] < m_b["recall"] | |
| assert m_b["recall"] == 1.0 | |