Détection symbolique de sophismes — l’étage symbolique du cœur EPITA

Ce notebook porte l’étage symbolique de la détection de sophismes tel qu’il vit dans le cœur du dépôt EPITA (argumentation_analysis/adapters/french_fallacy_adapter.py, _SYMBOLIC_FALLACY_RULES) : des motifs déclaratifs de tokens, exécutés par le vrai moteur (spaCy et son modèle français fr_core_news_sm), et une chaîne de justifications par famille.

La généalogie est explicite. Ces règles ont d’abord existé dans le sous-projet étudiant 2.3.2-detection-sophismes (jsboigeEpita/2025-Epita-Intelligence-Symbolique) ; le cœur les a ensuite consolidées — il en a retenu une partie, écarté une autre, et corrigé deux motifs que leur forme d’origine rendait inopérants (réparations G4, #1186). Ce notebook porte la version du cœur ; le détecteur neuronal CamemBERT de 1,8 Go reste hors périmètre (archéologique, absent du dépôt).

L’organe fallacy_rules.py vit à côté de ce notebook, dans le même dossier. Il est pur : sans spaCy installé, il s’importe et ses données sont vérifiables (tests de structure dans tests/test_fallacy_rules.py).

Plan : (1) comptes et structure des règles ; (2) détection sur un corpus français annoté ; (3) minage claim/prémisse ; (4) démonstration des deux réparations du cœur ; (5) limites mesurées ; (6) exercices.

import sys
from pathlib import Path

sys.path.insert(0, str(Path().resolve()))

import fallacy_rules as fr

fr.rule_counts()
{'families': 5,
 'rule_keys': 5,
 'fallacy_motifs': 13,
 'claim_motifs': 2,
 'premise_motifs': 2,
 'justification_templates': 4}

Lecture des comptes

L’organe porte 5 clés de règles couvrant 5 familles de sophismes, 13 motifs, 2 motifs de claim, 2 motifs de prémisse et 4 gabarits de justification. Ces comptes sont recomptés sur le fichier de l’organe, et ce sont exactement ceux de la consolidation du cœur : la re-fondation a retiré les deux motifs d’autorité qui reposaient sur une simple paire sujet-verbe ([NOUN] (le) dire, [PROPN] dire — sans marqueur d’autorité), le motif de prémisse les/des NOUN montrer/indiquer que, et fusionné les deux clés d’autorité en une seule, ARGUMENT_AUTORITE.

Les identifiants sont désormais non accentués (GENERALISATION_HATIVE, APPEL_A_LA_TRADITION, ARGUMENT_AUTORITE), comme dans le cœur : un identifiant accentué est un risque de régression (re-fondation #4960, commit c411fcff87), et les clés du cœur font autorité de nommage depuis la re-fondation.

Chaque motif est un dictionnaire spaCy Matcher : une liste de specs de tokens (attributs LOWER, LEMMA, POS, TEXT…) avec des opérateurs OP (*, ?, +). C’est cette forme déclarative qui rend l’étage distillable : les motifs sont des données, examinables, critiquables et extensibles sans toucher au moteur.

import spacy

nlp = spacy.load("fr_core_news_sm")
print("Modèle:", nlp.meta["name"], "| spaCy chargé.")

# Corpus français : une à deux phrases par famille + une phrase saine.
corpus = [
    ("Vous êtes incompétent, donc votre argument est invalide.", "AD_HOMINEM"),
    ("On ne peut pas faire confiance à un homme politique.", "AD_HOMINEM"),
    ("Si on autorise le mariage pour tous, alors on autorisera la polygamie.", "PENTE_GLISSANTE"),
    ("Le premier pas vers le déclin de la société.", "PENTE_GLISSANTE"),
    ("Tous les touristes sont désagréables.", "GENERALISATION_HATIVE"),
    ("On a toujours fait comme ça, pourquoi changer ?", "APPEL_A_LA_TRADITION"),
    ("Un expert a dit que le chocolat est bon pour la santé, donc c'est vrai.", "ARGUMENT_D_AUTORITE"),
    ("Selon un journaliste, la mesure est efficace.", "ARGUMENT_D_AUTORITE"),
    ("Le prix du pain a augmenté ce mois-ci.", "SAINE"),
]

resultats = []
for phrase, attendu in corpus:
    detections = fr.detect_fallacies(phrase, nlp=nlp)
    resultats.append({
        "phrase": phrase,
        "attendu": attendu,
        "détecté": detections[0]["key"] if detections else "—",
        "motif": f'#{detections[0]["pattern_index"]} « {detections[0]["excerpt"]} »' if detections else "—",
        "justif": "oui" if detections and detections[0]["justification"] else "non",
    })

for r in resultats:
    marque = "OK " if (r["attendu"].startswith(tuple(fr.FALLACY_LABELS)) or r["attendu"] == "SAINE") else "   "
    print(f'{marque} attendu={r["attendu"]:<22} détecté={r["détecté"]:<24} justif={r["justif"]:<4} {r["motif"]}')
Modèle: core_news_sm | spaCy chargé.
OK  attendu=AD_HOMINEM             détecté=—                        justif=non  —
OK  attendu=AD_HOMINEM             détecté=AD_HOMINEM_DIRECT        justif=oui  #1 « On ne peut pas faire confiance à un homme »
OK  attendu=PENTE_GLISSANTE        détecté=—                        justif=non  —
OK  attendu=PENTE_GLISSANTE        détecté=PENTE_GLISSANTE          justif=non  #2 « Le premier pas vers le »
OK  attendu=GENERALISATION_HATIVE  détecté=—                        justif=non  —
OK  attendu=APPEL_A_LA_TRADITION   détecté=APPEL_A_LA_TRADITION     justif=non  #0 « On a toujours fait comme ça »
OK  attendu=ARGUMENT_D_AUTORITE    détecté=—                        justif=non  —
OK  attendu=ARGUMENT_D_AUTORITE    détecté=ARGUMENT_AUTORITE        justif=oui  #1 « Selon un journaliste, »
OK  attendu=SAINE                  détecté=—                        justif=non  —
# Diagnostic : pourquoi la phrase ad hominem « propre » échoue.
# On affiche les attributs que les specs du motif 0 attendent, token par token.
cas_net = "Vous êtes incompétent, donc votre argument est invalide."
for tok in nlp(cas_net):
    print(f"{tok.text!r:<18} LEMMA={tok.lemma_:<12} POS={tok.pos_:<6} LOWER={tok.lower_}")
'Vous'             LEMMA=vous         POS=PRON   LOWER=vous
'êtes'             LEMMA=être         POS=AUX    LOWER=êtes
'incompétent'      LEMMA=incompéter   POS=VERB   LOWER=incompétent
','                LEMMA=,            POS=PUNCT  LOWER=,
'donc'             LEMMA=donc         POS=ADV    LOWER=donc
'votre'            LEMMA=votre        POS=DET    LOWER=votre
'argument'         LEMMA=argument     POS=NOUN   LOWER=argument
'est'              LEMMA=être         POS=AUX    LOWER=est
'invalide'         LEMMA=invalide     POS=ADJ    LOWER=invalide
'.'                LEMMA=.            POS=PUNCT  LOWER=.

Lecture du corpus : quatre déclarations, cinq silences, un diagnostic

Sur le modèle sm, quatre phrases sur neuf déclenchent : « On ne peut pas faire confiance à » (motif 1, ad hominem par discrédit généralisé), « Le premier pas vers » (motif 2 de la pente glissante), « On a toujours fait comme ça » (motif 0 de l’appel à la tradition) et « Selon un journaliste, » (motif 1 de l’argument d’autorité) — et la phrase saine sur le prix du pain reste silencieuse, témoin négatif attendu. Deux de ces quatre détections portent une justification ; les deux autres en sont dépourvues, et ce n’est pas un oubli : les gabarits de justification couvrent trois familles sur cinq (voir les limites mesurées).

Ce qui distingue les quatre gagnantes : ce sont des motifs lexicaux, des séquences de mots-outils exacts, insensibles au taggeur. Les cinq silences sont tous syntaxiques — ils dépendent des specs POS/LEMMA — et le diagnostic ci-dessus montre les causes pour la plus simple d’entre elles : dans « Vous êtes incompétent, donc », la virgule s’intercale entre l’adjectif et le connecteur (le motif 0 prévoit ADJ puis donc sans ponctuation entre les deux), et surtout le taggeur sm étiquette « incompétent » VERB (lemme « incompéter »), pas ADJ — la spec POS: ADJ ne peut donc jamais s’allumer sur ce mot sous ce modèle. Même famille de cause pour les autres : « Tous les touristes » insère un déterminant que le gabarit QUANTIFICATEUR → NOM+ n’a pas prévu, et « Si on autorise le mariage » fait de même côté du verbe.

La leçon de mesure : un moteur à motifs figés a une précision élevée (ce qu’il signale, il le signale pour la bonne raison) et un rappel très faible sur du français réel (4/9 ici) — chaque mot fonctionnel oublié du gabarit (virgule, déterminant, adverbe) suffit à casser la reconnaissance. La section qui suit montre que le cœur a réparé deux de ces gabarits, et que la réparation se mesure.

# La paire avec/sans adverbe : les DEUX échouent, pour des raisons cumulées.
cas_adverbe = "Vous êtes vraiment incompétent, donc votre argument est invalide."
print("Avec adverbe :", fr.detect_fallacies(cas_adverbe, nlp=nlp) or "aucune détection")
print("Sans adverbe :", fr.detect_fallacies(cas_net, nlp=nlp) or "aucune détection")

# Minage claim/prémisse sur un énoncé argumentatif complet.
argument = ("Je pense que la réforme est nécessaire, parce que les études montrent que "
            "les coûts ont triplé. En conclusion, il faut agir maintenant.")
minage = fr.mine_claims_premises(argument, nlp=nlp)
print("Claims   :", [c["excerpt"] for c in minage["claims"]])
print("Premisses:", [p["excerpt"] for p in minage["premises"]])
Avec adverbe : aucune détection
Sans adverbe : aucune détection
Claims   : ['Je pense que la réforme est nécessaire, parce que les études montrent que les coûts ont triplé. En conclusion, il faut agir maintenant.', 'En conclusion, il faut agir maintenant.']
Premisses: ['parce que les études montrent que les coûts ont triplé. En conclusion, il faut agir maintenant.']

Lecture : géométrie du silence, puis minage propre

La paire confirme le diagnostic : les deux variantes échouent, et pour des raisons qui se cumulent — la virgule pour les deux, l’adverbe vraiment (spec ADV absente du gabarit) en plus pour la seconde. C’est la géométrie exacte de l’angle mort : chaque élément non prévu entre deux specs attendues ajoute une distance que le motif ne sait pas franchir, et les distances s’additionnent.

Le minage, lui, rend des extraits propres : une seule occurrence par marqueur. Ce n’est pas le comportement brut du Matcher — le joker OP: "+" du source, posé sur une spec TEXT quelconque, fait rendre au Matcher toutes les longueurs possibles depuis le même point de départ (des extraits imbriqués « Je pense que la », « Je pense que la réforme », « Je pense que la réforme est »…). L’organe déduplique en conservant le plus long match par position de départ : sémantique du marqueur conservée, bruit d’affichage supprimé.

Cette géométrie n’est pas une fatalité. Si la virgule casse un motif parce qu’un seul slot manque, alors ajouter ce slot répare le motif — c’est exactement le geste que la consolidation du cœur a fait sur deux d’entre eux, et la section suivante le mesure.

# Les deux motifs que la consolidation du cœur a restaurés en les corrigeant (#1186).
cas_ad = "Pierre est malhonnête, donc son argument est faux."
cas_gen = "Sur la base de 5 exemples, il conclut que tout le monde ment."

print("Diagnostic du cas ad hominem, token par token :")
for tok in nlp(cas_ad):
    print(f"  {tok.text!r:<14} LEMMA={tok.lemma_:<12} POS={tok.pos_:<6}")

from spacy.matcher import Matcher


def nb_matches(pattern, texte):
    """Nombre de matches d'un motif sur un texte (matcher reconstruit à chaque appel)."""
    matcher = Matcher(nlp.vocab)
    matcher.add("motif", [pattern])
    return len(matcher(nlp(texte)))


# Forme d'origine (projet étudiant) vs forme réparée par le cœur, motif par motif.
ad_origine = [
    {"POS": "PROPN"}, {"LEMMA": "être"}, {"POS": "ADJ"},
    {"LOWER": {"IN": ["donc", "alors"]}},
    {"POS": "DET"}, {"POS": "NOUN"}, {"LEMMA": "être"}, {"LOWER": "faux"},
]
ad_coeur = ad_origine[:3] + [{"IS_PUNCT": True, "OP": "?"}] + ad_origine[3:]

gen_origine = [
    {"LOWER": "sur"}, {"LOWER": "la"}, {"LOWER": "base"}, {"LOWER": "de"},
    {"POS": "NUM"}, {"POS": "NOUN"}, {"LOWER": "exemples"},
]
gen_coeur = [
    {"LOWER": "sur"}, {"LOWER": "la"}, {"LOWER": "base"}, {"LOWER": "de"},
    {"POS": "NUM"}, {"LOWER": "exemples"},
]

print()
for nom, origine, coeur, cas in [
    ("AD_HOMINEM_DIRECT #2", ad_origine, ad_coeur, cas_ad),
    ("GENERALISATION_HATIVE #1", gen_origine, gen_coeur, cas_gen),
]:
    print(f"{nom:26s} — origine : {nb_matches(origine, cas)} match | cœur : {nb_matches(coeur, cas)} match")

print()
for cas in (cas_ad, cas_gen):
    for d in fr.detect_fallacies(cas, nlp=nlp):
        just = d["justification"] or "aucun gabarit pour cette famille (fail-loud)"
        print(f'{d["key"]} #{d["pattern_index"]} : « {d["excerpt"]} »')
        print(f"    justification : {just}")

print()
print("Couverture des gabarits de justification (5 familles) :")
for famille, label in fr.FALLACY_LABELS.items():
    gabarit = fr.justify_fallacy(label)
    print(f'  {famille:<24} {"gabarit" if gabarit else "None (fail-loud)"}')
Diagnostic du cas ad hominem, token par token :
  'Pierre'       LEMMA=Pierre       POS=PROPN 
  'est'          LEMMA=être         POS=AUX   
  'malhonnête'   LEMMA=malhonnête   POS=ADJ   
  ','            LEMMA=,            POS=PUNCT 
  'donc'         LEMMA=donc         POS=ADV   
  'son'          LEMMA=son          POS=DET   
  'argument'     LEMMA=argument     POS=NOUN  
  'est'          LEMMA=être         POS=AUX   
  'faux'         LEMMA=faux         POS=ADJ   
  '.'            LEMMA=.            POS=PUNCT 

AD_HOMINEM_DIRECT #2       — origine : 0 match | cœur : 1 match
GENERALISATION_HATIVE #1   — origine : 0 match | cœur : 1 match

AD_HOMINEM_DIRECT #2 : « Pierre est malhonnête, donc son argument est faux »
    justification : L'argument attaque la personne ou le caractère de l'adversaire plutôt que de réfuter son argument.
GENERALISATION_HATIVE #1 : « Sur la base de 5 exemples »
    justification : Une conclusion générale est tirée à partir d'un échantillon trop limité ou non représentatif.

Couverture des gabarits de justification (5 familles) :
  AD_HOMINEM               gabarit
  PENTE_GLISSANTE          None (fail-loud)
  GENERALISATION_HATIVE    gabarit
  APPEL_A_LA_TRADITION     None (fail-loud)
  ARGUMENT_D_AUTORITE      gabarit

Lecture : deux motifs que la consolidation a rendus vivants

Le tableau est net : les deux motifs ne déclenchaient pas sous leur forme d’origine, et déclenchent sous la forme réparée par le cœur. Le diagnostic token par token du cas ad hominem montre que l’obstruction était unique — la virgule entre l’adjectif et le connecteur ; tout le reste du gabarit s’aligne, y compris est, qui porte bien le lemme être quoique taggé AUX plutôt que VERB : la spec s’appuie sur le lemme, pas sur le POS. Ajouter le slot de ponctuation optionnel {"IS_PUNCT": True, "OP": "?"} suffit à faire passer le motif de 0 à 1 match.

La seconde réparation est de nature différente. Dans « Sur la base de 5 exemples », le slot NOUN surnuméraire du gabarit d’origine attendait un nom avant exemples — qui est lui-même le nom. Deux obstructions là où il n’y avait pas la place d’en franchir une. Le cœur a retiré le slot ; le motif passe de 0 à 1 match.

Ce que ces deux réparations enseignent vaut au-delà de ce notebook : un motif qui ne déclenche jamais ne se voit pas. Il ne produit ni erreur, ni faux positif, ni trace — seulement un silence indiscernable d’une absence légitime de sophisme. Seule la mesure de rappel sur un corpus annoté (celle de la section précédente) le révèle : 4 détections sur 9 phrases disent à la fois ce que le moteur attrape et qu’il en manque.

La réparation n’est pas cosmétique : elle a été motivée par un incident (#1186) et le cœur l’a annotée à l’endroit exact du gabarit, pour que le prochain lecteur sache pourquoi ce slot existe.

Limites mesurées et portée

  1. Modèle-dépendance : les specs LEMMA/POS sont évaluées par le modèle chargé. Cette exécution utilise fr_core_news_sm (léger) ; le cœur utilise fr_core_news_lg (~500 Mo). Le diagnostic du corpus en donne la preuve directe : « incompétent » est VERB sous sm — sous lg ou avec un autre mot, la même phrase peut détecter. La sortie committée ici est celle de sm, reproductible à l’identique.
  2. Rappel borné par construction, et mesuré : 4 détections sur 9 phrases du corpus (modèle sm) — les motifs lexicaux passent, les syntaxiques tombent sur les éléments non prévus (virgule, déterminant, adverbe). La démonstration G4 montre que ces obstructions sont réparables une à une, mais qu’elles ne se voient qu’au banc : le corpus labellisé du cœur permettrait de mesurer précision et rappel sur du texte réel — c’est le complément naturel de ce grain.
  3. Justification partielle, par construction : les 4 gabarits de justification couvrent 3 des 5 familles. La pente glissante et l’appel à la tradition n’en ont pas : leur détection rend None, et l’organe ne fabrique pas de texte à leur place (fail-loud #1019). Un détecteur honnête dit « je ne sais pas l’expliquer » plutôt que d’improviser.
  4. Divergences conservées, mesurées : les specs {"OP": "+"} nues du source sont normalisées en jokers explicites — choix de lisibilité, pas contrainte : spaCy 3.8.16 accepte aussi la spec nue, et les deux formes rendent les mêmes matches (mesure du 2026-09-23) ; le minage déduplique par le plus long match. Tout est consigné dans la docstring de fallacy_rules.py.

Exercices

Les cellules suivantes sont à compléter. Elles s’exécutent sans erreur sur des stubs — le notebook doit toujours tourner de bout en bout.

Exercice 1 — Réparer le motif ad hominem, une cause à la fois

La phrase « Vous êtes incompétent, donc votre argument est invalide. » échoue pour trois raisons cumulées, toutes visibles dans la cellule de diagnostic : la virgule (PUNCT) entre l’adjectif et le connecteur, l’étiquette POS de « incompétent » qui est VERB et non ADJ sous ce modèle, puis — dans la variante avec adverbe — l’adverbe entre être et l’adjectif.

La consolidation du cœur a réparé cette classe d’obstruction sur deux autres motifs (section « deux motifs que la consolidation a rendus vivants ») ; ici, c’est le motif 0 d’AD_HOMINEM_DIRECT qui est en cause. Ajoutez-lui un nouveau motif (sans modifier les existants) qui traite les trois causes. Vérifiez sur les DEUX phrases : elles doivent détecter toutes les deux. Étape de vérification : les trois phrases saines de l’exercice 3 ne doivent pas se mettre à détecter.

# TODO etudiant : construire nouveau_motif (liste de specs de tokens) puis :
# fr.FALLACY_RULES["AD_HOMINEM_DIRECT"].append({"PATTERN": nouveau_motif, "FALLACY_TYPE": "Attaque personnelle (Ad Hominem)"})
# puis re-executer fr.detect_fallacies sur les deux phrases et imprimer les resultats.
# Indice : partez du motif 0 (voir fallacy_rules.py) et (1) remplacez la spec ADJ par
# {"POS": {"IN": ["ADJ", "VERB"]}}, (2) inserez {"POS": "ADV", "OP": "?"} avant elle,
# (3) inserez {"POS": "PUNCT", "OP": "?"} avant le connecteur donc.
nouveau_motif = None  # TODO etudiant
resultat_exercice_1 = None  # TODO etudiant
print("Exercice 1 a completer")
Exercice 1 a completer

Exercice 2 — Une phrase, deux familles

Construisez une phrase française qui déclenche au moins deux familles différentes en une seule passe de detect_fallacies (par exemple pente glissante + appel à la tradition). Imprimez la liste des détections avec leurs familles. Contrainte d’honnêteté : la phrase doit être grammaticale et les deux détections doivent venir de motifs différents de l’organe, pas d’un motif que vous auriez ajouté.

# TODO etudiant : ecrire phrase_double puis detections = fr.detect_fallacies(phrase_double, nlp=nlp)
phrase_double = None  # TODO etudiant
print("Exercice 2 a completer")
Exercice 2 a completer

Exercice 3 — Témoin négatif

Proposez trois phrases saines (aucun sophisme) dont deux contiennent quand même des marqueurs superficiellement proches (par ex. « si … alors » sans chaîne catastrophique, ou un « selon » citant une source qualifiée). Vérifiez que detect_fallacies rend une liste vide sur au moins deux d’entre elles ; si une phrase saine déclenche un faux positif, nommez le motif coupable et expliquez pourquoi le gabarit est trop large.

# TODO etudiant : completer saines = [...] puis boucle de verification + commentaire.
saines = []  # TODO etudiant
print("Exercice 3 a completer")
Exercice 3 a completer

Conclusion

Ce notebook a porté l’étage symbolique du détecteur de sophismes tel qu’il vit dans le cœur EPITA : un organe déclaratif de 13 motifs et 4 motifs de minage, exécutable par le vrai moteur (spaCy), dont le comportement est montré tel quel — succès lexicaux, témoin négatif silencieux, angle mort mesuré de l’adverbe, et deux réparations mesurées que la consolidation a apportées aux motifs hérités du projet étudiant.

La leçon la plus transférable est celle des deux sections centrales : un motif qui ne déclenche jamais est invisible — il ne lève rien, ne signale rien, et une règle morte ressemble exactement à une règle qui n’a rien à dire. Seul un banc annoté les distingue, et c’est pourquoi le cœur a attaché à ces deux réparations le numéro de l’incident (#1186) qui les a motivées.

La suite naturelle — banc précision/rappel sur un corpus labellisé, justification complète des cinq familles — reste ouverte, rangée en sous-grains dans le recensement de l’EPIC #4960.

Retour au sommet