From fbf5d5a0805615f22ec0bbb8877b38628cf72451 Mon Sep 17 00:00:00 2001 From: jsboige Date: Fri, 23 Oct 2026 19:44:42 +0200 Subject: [PATCH 1/2] feat(fallacydetection,#20222): tirage echantillon evaluation humaine -- script stratifie + tests (critere 1, etape 3) - draw_evaluation_sample.py : jointure corpus MAFALDA -> L2 papier (Helwe 2023, Figure 2, copie archivee GDrive) -> inventaire 23 classes -> CSV Argumentum ; unite = paire (texte, noeud) dedupliee par noeud ; tirage stratifie (famille x verdict) deterministe, plancher 10/famille, NOT_FOUND jamais tire. - Sorties : sample JSONL references-only (pas de texte, corpus CC-BY-SA), manifest JSON (population, strates, bornes, SHA-256). - Mesure seed 42 : population joinable 257, echantillon 200 (176 DIRECT + 24 PARTIAL), 6/7 familles, planchers tenus ; bornes documentees : Tricherie absente de la population, Influence PARTIAL-seule, 9 textes 'appeal to worse problems' (L2 papier sans inventaire). - tests : 7 passed (determinisme, bornes, dedup, exclusions, table rollup). Co-Authored-By: Claude Sonnet 5.5 --- .../draw_evaluation_sample.py | 310 ++++++++++++++++++ .../tests/test_draw_evaluation_sample.py | 113 +++++++ 2 files changed, 423 insertions(+) create mode 100644 scripts/fallacy_detection/draw_evaluation_sample.py create mode 100644 scripts/fallacy_detection/tests/test_draw_evaluation_sample.py diff --git a/scripts/fallacy_detection/draw_evaluation_sample.py b/scripts/fallacy_detection/draw_evaluation_sample.py new file mode 100644 index 00000000000..064225583cf --- /dev/null +++ b/scripts/fallacy_detection/draw_evaluation_sample.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Tirage de l'echantillon d'evaluation humaine du corpus academique aligne. + +Critere 1 de l'issue #20222 (etape 3 de la tranche C, EPIC #17578/#10355) : +un echantillon de paires (texte academique, noeud de taxonomie Argumentum), +stratifie par famille de premier niveau, graine fixee, empreinte SHA-256 +consignee. + +Unite d'annotation : la paire (texte, noeud). Un texte multi-etiquete produit +une paire par libelle de sophisme DISTINCT qu'il porte ; deux libelles qui +roulent vers le meme noeud (ex. appeal to anger et appeal to pity, tous deux +-> Appeal to emotion) ne produisent QU'UNE paire -- l'evaluateur juge « ce +texte illustre-t-il le noeud X » une fois par noeud. + +Chainon de jointure (trois niveaux, tous SOURCES) : + 1. libelle corpus MAFALDA (minuscule, fin) -> classe L2 du papier + (Helwe et al. 2023, arXiv:2311.09761, Figure 2 -- le papier definit + L1 = Pathos/Ethos/Logos, L2 = les classes fines ; les libelles du corpus + SONT les classes L2 du papier) ; + 2. classe L2 papier -> inventaire consolide du script d'alignement + (align_academic_to_argumentum.py, liste MAFALDA_L2 a 23 classes) -- la + consolidation suit les mots-cles du script (ex. alias « ad populum » de + Appeal to popularity, ligne 140) et le regroupement Pathos du papier + (les sous-emotions -> Appeal to emotion) ; + 3. inventaire -> noeud Argumentum via logic_mafalda_to_argumentum.csv + (verdict DIRECT / PARTIAL / NOT_FOUND, PR #20315). + +Strates : la strate d'evaluation est le croisement (famille Argumentum x +verdict d'alignement). Le tirage par defaut cible les strates DIRECT (le +critere evalue qu'un humain RETROUVE l'etiquette ; une paire dont +l'alignement meme est NOT_FOUND n'a pas d'etiquette a retrouver) puis +complete par PARTIAL sous contrainte de plancher par famille. + +Sorties : + --out-sample : JSONL, une paire par ligne -- references (fichier, index de + ligne, libelle corpus, inventaire, noeud, famille, verdict), + JAMAIS le texte lui-meme (pas de derivation CC-BY-SA, le + texte se re-lit depuis le corpus sous NOTICE) ; + --out-manifest: JSON -- graine, SHA-256 de l'echantillon, comptes par + strate, population joinable totale, bornes atteintes. + +Usage : + python scripts/fallacy_detection/draw_evaluation_sample.py \ + --seed 42 --out-sample sample.jsonl --out-manifest manifest.json +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import random +import sys +from collections import Counter, defaultdict +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +DATA_ACademic = REPO_ROOT / "MyIA.AI.Notebooks/GenAI/FallacyDetection/data/academic" +ALIGNMENT_CSV = DATA_ACademic / "logic_mafalda_to_argumentum.csv" +CORPUS_FILES = [ + "MAFALDA/gold_standard_dataset.jsonl", + "MAFALDA/user_study_examples_with_labels.jsonl", +] + +# Etiquettes portees par le corpus mais exclues par conception : 'nothing' +# (pas un sophisme) et 'to clean' (artefact de nettoyage du jeu amont, +# absent des guidelines d'annotation -- verifie sur la copie archivee +# « MAFALDA Annotation Guidelines », gisement GDrive). +EXCLUDED_LABELS = {"nothing", "to clean"} + +# Rollup libelle corpus -> inventaire consolide du script d'alignement. +# Source par entree : +# - DIRECT : le libelle corpus est le meme libelle que l'inventaire +# (casse pres) ; +# - PAPER-FIG2 : regroupement Pathos du papier (Helwe 2023, Figure 2) -- +# les sous-emotions sont des enfants L2 de Appeal to Emotion ; +# - SCRIPT-KW : mot-cle/alias de l'inventaire (align_academic_to_argumentum.py, +# liste MAFALDA_L2) ; +# - ETHOS : attaque du locuteur (papier, categorie Ethos de la Figure 2). +CORPUS_TO_INVENTORY = { + "slippery slope": ("Slippery slope", "DIRECT"), + "hasty generalization": ("Hasty generalization", "DIRECT"), + "false analogy": ("False analogy", "DIRECT"), + "circular reasoning": ("Circular reasoning", "DIRECT"), + "appeal to fear": ("Appeal to fear", "DIRECT"), + "appeal to nature": ("Appeal to nature", "DIRECT"), + "appeal to tradition": ("Appeal to tradition", "DIRECT"), + "fallacy of division": ("Fallacy of division", "DIRECT"), + "equivocation": ("Equivocation", "DIRECT"), + "straw man": ("Straw man", "DIRECT"), + "ad hominem": ("Ad hominem", "DIRECT"), + "appeal to pity": ("Appeal to emotion", "PAPER-FIG2"), + "appeal to anger": ("Appeal to emotion", "PAPER-FIG2"), + "appeal to positive emotion": ("Appeal to emotion", "PAPER-FIG2"), + "appeal to ridicule": ("Appeal to emotion", "PAPER-FIG2"), + "ad populum": ("Appeal to popularity", "SCRIPT-KW"), + "appeal to (false) authority": ("Appeal to authority", "SCRIPT-KW"), + "causal oversimplification": ("Causal fallacy", "SCRIPT-KW"), + "false causality": ("False cause", "SCRIPT-KW"), + "guilt by association": ("Ad hominem", "ETHOS"), + "tu quoque": ("Ad hominem", "ETHOS"), + "false dilemma": ("Fallacy of logic", "SCRIPT-KW"), +} + +# Libelles L2 du papier SANS pendant dans l'inventaire consolide : le papier +# (Figure 2) compte « Appeal to Worse Problem » parmi ses classes L2, mais la +# consolidation a 23 classes de align_academic_to_argumentum.py n'en retient +# pas -- ces paires sont non joinables PAR CONSTRUCTION et le manifest les +# signale a part (elles ne relevent d'aucun noeud Argumentum). +PAPER_L2_SANS_INVENTAIRE = {"appeal to worse problems"} + +# Seuils du protocole (issue #20222, critere 1) et bornes atteignables +# mesurees sur le corpus in-repo (220 textes, mesure au 2026-10-23) : +# population joinable 257 paires (texte x noeud) >= les 200 exigees ; +# Influence ne joint qu'en PARTIAL (sa strate DIRECT n'existe pas dans la +# population) ; Tricherie est absente de la population joinable (aucun +# libelle corpus ne roule vers elle) -- bornes consignees dans le manifest. +TARGET_TOTAL = 200 +FAMILY_FLOOR = 10 + + +def load_alignment() -> dict[str, dict]: + with open(ALIGNMENT_CSV, encoding="utf-8") as f: + return {r["academic_label"]: r for r in csv.DictReader(f)} + + +def build_pairs(alignment: dict[str, dict]) -> tuple[list[dict], Counter, Counter]: + """Construit la population joinable : une paire (texte, noeud) dedupliquee + par noeud, plus les compteurs de libelles non joinables (pour le rapport). + + Le lookup rollup est insensible a la casse : le corpus melange les + graphies ('appeal to ridicule' et 'Appeal to Ridicule' sont deux ecritures + du meme libelle L2 du papier). + """ + rollup_ci = {k.lower(): v for k, v in CORPUS_TO_INVENTORY.items()} + pairs: dict[tuple[str, int, str], dict] = {} + unjoinable: Counter = Counter() + unaligned_inventory: Counter = Counter() + for rel in CORPUS_FILES: + path = DATA_ACademic / rel + with open(path, encoding="utf-8") as f: + for idx, line in enumerate(f): + doc = json.loads(line) + labels = {lab for _, _, lab in doc["labels"]} + seen_nodes: set[str] = set() + for lab in sorted(labels): + if lab in EXCLUDED_LABELS: + continue + entry = rollup_ci.get(lab.lower()) + if entry is None: + unjoinable[lab.lower()] += 1 + continue + inv, _source = entry + al = alignment.get(inv) + if al is None: + unaligned_inventory[inv] += 1 + continue + node = al["best_name"] + if node in seen_nodes: + continue # dedup par noeud : une paire par (texte, noeud) + seen_nodes.add(node) + pairs[(rel, idx, node)] = { + "source_file": rel, + "line_index": idx, + "corpus_label": lab, + "inventory_label": inv, + "node": node, + "family": al["best_famille"], + "verdict": al["verdict"], + } + return list(pairs.values()), unjoinable, unaligned_inventory + + +def stratified_draw(pairs: list[dict], seed: int, total: int, floor: int) -> list[dict]: + """Tirage stratifie deterministe (famille x verdict). + + Ordre de priorite : strates DIRECT d'abord (jusqu'au plancher par famille, + puis au prorata), complement PARTIAL ensuite. Les strates NOT_FOUND ne + sont jamais tirees : leur etiquette n'est pas censee etre retrouvable. + """ + rng = random.Random(seed) + strata: dict[tuple[str, str], list[dict]] = defaultdict(list) + for p in pairs: + strata[(p["family"], p["verdict"])].append(p) + for members in strata.values(): + members.sort(key=lambda p: (p["source_file"], p["line_index"], p["node"])) + rng.shuffle(members) + + direct = {k: v for k, v in strata.items() if k[1] == "DIRECT"} + partial = {k: v for k, v in strata.items() if k[1] == "PARTIAL"} + picked: list[dict] = [] + + familles_direct = defaultdict(list) + for (fam, _v), members in direct.items(): + familles_direct[fam].extend(members) + + # 1. plancher par famille sur les strates DIRECT (dans la limite de la + # population -- une famille sous le plancher est prise en entier, la + # borne est consignee, pas maquillee). + for fam in sorted(familles_direct): + members = familles_direct[fam] + take = min(floor, len(members)) + picked.extend(members[:take]) + + # 2. compler vers le total au prorata des strates DIRECT restantes. + remaining = total - len(picked) + if remaining > 0: + pool: list[dict] = [] + for fam in sorted(familles_direct): + pool.extend(familles_direct[fam][min(floor, len(familles_direct[fam])):]) + take = min(remaining, len(pool)) + picked.extend(pool[:take]) + remaining -= take + # 3. si DIRECT epuise, complement PARTIAL (documente dans le manifest). + if remaining > 0: + ppool: list[dict] = [] + for key in sorted(partial): + ppool.extend(partial[key]) + picked.extend(ppool[: min(remaining, len(ppool))]) + + picked.sort(key=lambda p: (p["source_file"], p["line_index"], p["node"])) + return picked + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--seed", type=int, default=42, help="graine du tirage (consignee)") + parser.add_argument("--total", type=int, default=TARGET_TOTAL, + help=f"cible de paires (défaut {TARGET_TOTAL})") + parser.add_argument("--floor", type=int, default=FAMILY_FLOOR, + help=f"plancher par famille (défaut {FAMILY_FLOOR})") + parser.add_argument("--out-sample", type=Path, required=True) + parser.add_argument("--out-manifest", type=Path, required=True) + parser.add_argument("--report-only", action="store_true", + help="mesure la population et sort le manifest, sans echantillon") + args = parser.parse_args(argv) + + alignment = load_alignment() + pairs, unjoinable, unaligned = build_pairs(alignment) + + strata_counts = Counter((p["family"], p["verdict"]) for p in pairs) + family_counts = Counter(p["family"] for p in pairs) + + sample: list[dict] = [] + if not args.report_only: + sample = stratified_draw(pairs, args.seed, args.total, args.floor) + with open(args.out_sample, "w", encoding="utf-8") as f: + for p in sample: + f.write(json.dumps(p, ensure_ascii=False) + "\n") + sample_sha = sha256_file(args.out_sample) + else: + sample_sha = None + + manifest = { + "issue": 20222, + "seed": args.seed, + "target_total": args.total, + "family_floor": args.floor, + "population_joinable": len(pairs), + "population_par_famille": dict(sorted(family_counts.items())), + "population_par_strate": { + f"{fam} x {verdict}": n for (fam, verdict), n in sorted(strata_counts.items()) + }, + "libelles_exclus": sorted(EXCLUDED_LABELS), + "paper_l2_sans_inventaire": { + lab: n for lab, n in sorted(unjoinable.items()) if lab in PAPER_L2_SANS_INVENTAIRE + }, + "libelles_non_joinables": { + lab: n for lab, n in sorted(unjoinable.items()) + if lab not in PAPER_L2_SANS_INVENTAIRE + }, + "inventaire_sans_alignement": dict(unaligned), + "echantillon_taille": len(sample), + "echantillon_par_famille": dict(sorted(Counter(p["family"] for p in sample).items())), + "echantillon_par_verdict": dict(sorted(Counter(p["verdict"] for p in sample).items())), + "echantillon_sha256": sample_sha, + "bornes": { + "critere_200_atteint": len(sample) >= 200, + "familles_sous_plancher": sorted( + f for f, n in Counter(p["family"] for p in sample).items() if n < args.floor + ), + "familles_absentes_de_la_population": sorted( + set(al["best_famille"] for al in alignment.values()) - set(family_counts) + ), + }, + } + with open(args.out_manifest, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=False, indent=2) + + print(json.dumps({ + "population": len(pairs), + "echantillon": len(sample), + "sha256": sample_sha, + "critere_200": manifest["bornes"]["critere_200_atteint"], + "familles_sous_plancher": manifest["bornes"]["familles_sous_plancher"], + }, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/fallacy_detection/tests/test_draw_evaluation_sample.py b/scripts/fallacy_detection/tests/test_draw_evaluation_sample.py new file mode 100644 index 00000000000..2b09484462f --- /dev/null +++ b/scripts/fallacy_detection/tests/test_draw_evaluation_sample.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Tests du tirage d'echantillon d'evaluation humaine (#20222, critere 1). + +Couvre : determinisme graine, bornes de stratification, regle d'unite +(texte x noeud, dedup), exclusions par conception, exclusion NOT_FOUND, +integrite de la table de rollup contre le CSV d'alignement. +""" +from __future__ import annotations + +import importlib.util +import json +import sys +from collections import Counter +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "scripts/fallacy_detection/draw_evaluation_sample.py" +ALIGNMENT_CSV = REPO_ROOT / "MyIA.AI.Notebooks/GenAI/FallacyDetection/data/academic/logic_mafalda_to_argumentum.csv" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("draw_evaluation_sample", SCRIPT) + mod = importlib.util.module_from_spec(spec) + sys.modules.setdefault("draw_evaluation_sample", mod) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def mod(): + return _load_module() + + +@pytest.fixture(scope="module") +def population(mod): + pairs, _unjoinable, _unaligned = mod.build_pairs(mod.load_alignment()) + return pairs + + +def test_rollup_joinable_complet(mod): + """Apres la correction case-insensitive + 'ad hominem', aucun libelle du + corpus ne reste hors la table (les exclusions par conception mises a part). + """ + _, unjoinable, _ = mod.build_pairs(mod.load_alignment()) + inattendus = {l for l in unjoinable if l not in mod.PAPER_L2_SANS_INVENTAIRE} + assert not inattendus, f"libelles non joinables hors papier-L2 : {inattendus}" + + +def test_rollup_vers_csv(population, mod): + """Chaque cible d'inventaire de la table de rollup existe dans le CSV + d'alignement (aucune cle morte).""" + import csv + with open(ALIGNMENT_CSV, encoding="utf-8") as f: + labels = {r["academic_label"] for r in csv.DictReader(f)} + for _corpus, (inv, _src) in mod.CORPUS_TO_INVENTORY.items(): + assert inv in labels, f"{inv!r} absent du CSV d'alignement" + + +def test_unite_texte_noeud(population): + """Une paire par (texte, noeud) : pas de doublon.""" + seen = [(p["source_file"], p["line_index"], p["node"]) for p in population] + assert len(seen) == len(set(seen)) + + +def test_exclusions_par_conception(population, mod): + """'nothing' et 'to clean' ne produisent jamais de paire.""" + assert "nothing" in mod.EXCLUDED_LABELS and "to clean" in mod.EXCLUDED_LABELS + assert all(p["corpus_label"] not in mod.EXCLUDED_LABELS for p in population) + + +def test_tirage_deterministe(mod, tmp_path): + """Meme graine -> meme empreinte SHA-256 (protocole, critere 1).""" + shas = [] + for i in range(2): + sample = tmp_path / f"s{i}.jsonl" + manifest = tmp_path / f"m{i}.json" + rc = mod.main(["--seed", "7", "--out-sample", str(sample), + "--out-manifest", str(manifest)]) + assert rc == 0 + shas.append(json.loads(manifest.read_text(encoding="utf-8"))["echantillon_sha256"]) + assert shas[0] == shas[1] + + +def test_tirage_bornes_et_not_found(mod, tmp_path): + """200 paires, plancher par famille tenu (population suffisante), et + AUCUNE paire NOT_FOUND : leur etiquette n'est pas retrouvable par + construction.""" + sample = tmp_path / "s.jsonl" + manifest = tmp_path / "m.json" + assert mod.main(["--seed", "42", "--out-sample", str(sample), + "--out-manifest", str(manifest)]) == 0 + rows = [json.loads(l) for l in sample.read_text(encoding="utf-8").splitlines()] + m = json.loads(manifest.read_text(encoding="utf-8")) + assert len(rows) == 200 == m["echantillon_taille"] + familles = Counter(r["family"] for r in rows) + for fam, n in familles.items(): + assert n >= 10, f"famille {fam} sous plancher : {n}" + assert all(r["verdict"] in ("DIRECT", "PARTIAL") for r in rows) + + +def test_tirage_synthetic_bornes_serreess(mod): + """Sur une population synthetique, une famille sous le plancher est prise + en entier et la borne est signalee, jamais maquillee.""" + tiny = [ + {"source_file": "a.jsonl", "line_index": i, "corpus_label": "x", + "inventory_label": "X", "node": f"n{i}", "family": "F", + "verdict": "DIRECT"} + for i in range(4) + ] + picked = mod.stratified_draw(tiny, seed=1, total=10, floor=10) + assert len(picked) == 4 # toute la population, plancher inatteignable From 2748a36a0457ea1cdebb07c029b555677744e966 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 24 Oct 2026 05:30:25 +0200 Subject: [PATCH 2/2] fix(fallacydetection,#20222): exclusion insensible a la casse + prorata reel des restes DIRECT Deux reserves NanoClaw verifiees vraies, corrigees chacune avec son test : - l.148 : EXCLUDED_LABELS est teste apres lab.lower() -- un libelle "Nothing" / "To Clean" est exclu par conception au lieu de finir dans le rapport non-joinable (le docstring promet l'insensibilite a la casse). - l.206 : le complement au-dela du plancher repart les sieges au prorata des restes DIRECT (plus fort reste a la D'Hondt, bornes par strate), au lieu du remplissage sequentiel pool[:take] qui servait les premieres familles triees et laissait les dernieres vides. Nouveaux tests : test_exclusion_insensible_casse (corpus synthetique, "nothing" absent du rapport non-joinable) et test_completion_au_prorata_des_restes (restes 3:1 -> FamA=25/FamB=15, echoue sur l''ancien remplissage FamA=30/FamB=10). Suite 9/9 verte. See #20222 Co-Authored-By: Claude Sonnet 5.5 --- .../draw_evaluation_sample.py | 47 +++++++++++++++---- .../tests/test_draw_evaluation_sample.py | 45 ++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/scripts/fallacy_detection/draw_evaluation_sample.py b/scripts/fallacy_detection/draw_evaluation_sample.py index 064225583cf..f00111a41ca 100644 --- a/scripts/fallacy_detection/draw_evaluation_sample.py +++ b/scripts/fallacy_detection/draw_evaluation_sample.py @@ -145,7 +145,7 @@ def build_pairs(alignment: dict[str, dict]) -> tuple[list[dict], Counter, Counte labels = {lab for _, _, lab in doc["labels"]} seen_nodes: set[str] = set() for lab in sorted(labels): - if lab in EXCLUDED_LABELS: + if lab.lower() in EXCLUDED_LABELS: continue entry = rollup_ci.get(lab.lower()) if entry is None: @@ -172,6 +172,32 @@ def build_pairs(alignment: dict[str, dict]) -> tuple[list[dict], Counter, Counte return list(pairs.values()), unjoinable, unaligned_inventory +def _prorata_quotas(remaining: int, sizes: dict[str, int]) -> dict[str, int]: + """Repartition proportionnelle deterministe des sieges restants. + + Methode du plus fort reste a la D'Hondt : chaque siege va a la strate + qui maximise taille/(sieges_deja_pris + 1), donc le quota final suit la + proportion des restes de chaque famille. Plafonne par construction a la + population de chaque strate (une strate epuisee ne recoit plus rien). + Egalites tranchees par cle triee : deux runs memes entrees donnent le + meme quota, graine mise a part. + """ + quotas = {k: 0 for k in sizes} + for _ in range(remaining): + best_k = None + best_rate = -1.0 + for k in sorted(sizes): + if quotas[k] >= sizes[k]: + continue + rate = sizes[k] / (quotas[k] + 1) + if rate > best_rate: + best_k, best_rate = k, rate + if best_k is None: + break # toutes les strates sont epuisees + quotas[best_k] += 1 + return quotas + + def stratified_draw(pairs: list[dict], seed: int, total: int, floor: int) -> list[dict]: """Tirage stratifie deterministe (famille x verdict). @@ -203,15 +229,20 @@ def stratified_draw(pairs: list[dict], seed: int, total: int, floor: int) -> lis take = min(floor, len(members)) picked.extend(members[:take]) - # 2. compler vers le total au prorata des strates DIRECT restantes. + # 2. completer vers le total au prorata des strates DIRECT restantes : + # chaque famille recoit un quota proportionnel a son reste, pas un + # remplissage sequentiel qui servirait les premieres familles trie + # et laisserait les dernieres vides. remaining = total - len(picked) if remaining > 0: - pool: list[dict] = [] - for fam in sorted(familles_direct): - pool.extend(familles_direct[fam][min(floor, len(familles_direct[fam])):]) - take = min(remaining, len(pool)) - picked.extend(pool[:take]) - remaining -= take + leftovers = { + fam: familles_direct[fam][min(floor, len(familles_direct[fam])):] + for fam in sorted(familles_direct) + } + quotas = _prorata_quotas(remaining, {f: len(v) for f, v in leftovers.items()}) + for fam in sorted(leftovers): + picked.extend(leftovers[fam][: quotas[fam]]) + remaining -= sum(quotas.values()) # 3. si DIRECT epuise, complement PARTIAL (documente dans le manifest). if remaining > 0: ppool: list[dict] = [] diff --git a/scripts/fallacy_detection/tests/test_draw_evaluation_sample.py b/scripts/fallacy_detection/tests/test_draw_evaluation_sample.py index 2b09484462f..2e979adf412 100644 --- a/scripts/fallacy_detection/tests/test_draw_evaluation_sample.py +++ b/scripts/fallacy_detection/tests/test_draw_evaluation_sample.py @@ -111,3 +111,48 @@ def test_tirage_synthetic_bornes_serreess(mod): ] picked = mod.stratified_draw(tiny, seed=1, total=10, floor=10) assert len(picked) == 4 # toute la population, plancher inatteignable + + +def test_exclusion_insensible_casse(mod, tmp_path, monkeypatch): + """Un libelle exclu ecrit avec une casse differente ('Nothing') est exclu + par conception lui aussi -- pas route vers le rapport non-joinable.""" + (tmp_path / "synthetic.jsonl").write_text( + json.dumps({"labels": [[0, 1, "Nothing"], [2, 3, "straw man"]], + "text": "t"}) + "\n", + encoding="utf-8", + ) + monkeypatch.setattr(mod, "CORPUS_FILES", ["synthetic.jsonl"]) + monkeypatch.setattr(mod, "DATA_ACademic", tmp_path) + align = {"Straw man": {"academic_label": "Straw man", + "best_name": "StrawManNode", "best_famille": "Fam", + "verdict": "DIRECT"}} + pairs, unjoinable, _unaligned = mod.build_pairs(align) + assert [p["corpus_label"] for p in pairs] == ["straw man"] + # 'Nothing' exclue par conception : elle ne doit PAS se retrouver dans + # les non-joinables (c'est le symptome du test de casse avant correctif). + assert "nothing" not in unjoinable + + +def test_completion_au_prorata_des_restes(mod): + """Le complement au-dela du plancher suit le prorata des restes DIRECT, + pas un remplissage sequentiel qui servirait la premiere famille triee.""" + def _famille(nom: str, n: int): + return [ + {"source_file": "a.jsonl", "line_index": i, "corpus_label": "x", + "inventory_label": "X", "node": f"{nom}{i}", "family": nom, + "verdict": "DIRECT"} + for i in range(n) + ] + + # Restes apres plancher 10 : FamA=30, FamB=10 ; total 40 -> 20 sieges + # complementaires, prorata exact 3:1 -> 15 pour FamA, 5 pour FamB. + population = _famille("FamA", 40) + _famille("FamB", 20) + picked = mod.stratified_draw(population, seed=3, total=40, floor=10) + familles = Counter(p["family"] for p in picked) + assert len(picked) == 40 + assert familles == {"FamA": 25, "FamB": 15} + # Le remplissage sequentiel d'avant donnait FamA=30/FamB=10 : ce test + # echoue sur ce comportement, c'est son role. + picked2 = mod.stratified_draw(population, seed=3, total=40, floor=10) + assert [(p["source_file"], p["node"]) for p in picked2] == \ + [(p["source_file"], p["node"]) for p in picked] # deterministe