SL-9 - LLMs et Apprentissage Symbolique : Generation et Verification d’Hypotheses

Navigation : Index | << Knowledge Graphs ILP

Objectifs d’apprentissage

A la fin de ce notebook, vous saurez : 1. Utiliser un LLM comme générateur d’hypotheses a partir d’exemples structures 2. Verifier formellement les règles generees par un LLM avec un oracle symbolique 3. Comprendre le parallele entre Chain-of-Thought et l’apprentissage base sur les explications (EBL) 4. Implementer un pipeline complet : exemples -> LLM -> candidats -> verification -> règles validees 5. Brancher un vrai LLM (OpenRouter, OpenAI ou modèle auto-heberge) via un fichier .env 6. Evaluer les forces et limites de l’approche neuro-symbolique pour l’apprentissage de règles

Prerequis

  • SL-1 a SL-4 : Apprentissage logique, EBL/RBL, ILP
  • Python 3.10+
  • Notions de base sur les LLMs (prompting, generation de texte)

Duree estimee : 40 minutes

References

  • Russell & Norvig, Artificial Intelligence: A Modern Approach, 4e ed., Chapitre 19
  • Wei et al., Chain-of-Thought Prompting Elicits Reasoning in Large Language Models (2022)
  • Muggleton, Inductive Logic Programming (1991)
  • Pan et al., Unifying Large Language Models and Knowledge Graphs: A Roadmap (2024)
# Imports et configuration
from typing import Optional
from dataclasses import dataclass, field
from collections import defaultdict
import itertools
import json
import re
import os
from dotenv import load_dotenv, find_dotenv

# --- Accès au LLM : VRAI modèle via .env, repli déterministe hors-ligne/CI ---
# Ce notebook appelle un vrai LLM comme generateur d'hypotheses (sections 2 a 7).
# Sans cle (pas de .env, ou environnement CI), il bascule automatiquement sur un
# generateur déterministe de référence : le notebook reste executable de bout en
# bout partout, et la cle API n'est JAMAIS affichee.
load_dotenv(find_dotenv(usecwd=True))
LLM_API_KEY = os.getenv("OPENAI_API_KEY")
LLM_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
LLM_MODEL = os.getenv("OPENAI_CHAT_MODEL_ID", "gpt-5.6-luna")
LLM_AVAILABLE = bool(LLM_API_KEY)
llm_client = None
if LLM_AVAILABLE:
    from openai import OpenAI  # importe seulement si une cle est presente
    llm_client = OpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)

# Metadata du notebook
NOTEBOOK_INFO = {
    "title": "SL-9 - LLMs et Apprentissage Symbolique",
    "series": "SymbolicLearning",
    "aima_chapters": ["19 (extension)"],
    "date": "2026-05",
}

print(f"Notebook : {NOTEBOOK_INFO['title']}")
print(f"Chapitres AIMA : {', '.join(NOTEBOOK_INFO['aima_chapters'])}")
if LLM_AVAILABLE:
    print("Generateur d'hypotheses : VRAI LLM (la cle API n'est jamais affichee)")
    print(f"  Endpoint : {LLM_BASE_URL}")
    print(f"  Modele   : {LLM_MODEL}")
else:
    print("Generateur d'hypotheses : repli deterministe (pas de .env / OPENAI_API_KEY)")
    print("  Copiez .env.example -> .env pour activer le vrai LLM.")
print("Verificateur : oracle symbolique deterministe (section 3) -- toujours actif.")
Notebook : SL-9 - LLMs et Apprentissage Symbolique
Chapitres AIMA : 19 (extension)
Generateur d'hypotheses : VRAI LLM (la cle API n'est jamais affichee)
  Endpoint : https://models.myia.io/v1
  Modele   : gpt-5.6-sol
Verificateur : oracle symbolique deterministe (section 3) -- toujours actif.

1. Introduction : LLMs comme moteurs de raisonnement symbolique

Les grands modèles de langage (LLMs) comme GPT-4, Claude ou Gemini sont capables de generer du texte structure, y compris des règles logiques et des explications de raisonnement. Cette capacite ouvre une nouvelle voie pour l’apprentissage symbolique :

Le paradigme neuro-symbolique pour l’apprentissage

Étape Rôle Analogue classique
Generation d’hypotheses LLM (neural) Exploration heuristique
Verification formelle Oracle symbolique Test de consistance
Sélection et raffinement Pipeline iteratif CBH / Version Space

Pourquoi combiner LLMs et symbolique ?

  1. LLMs sont creatifs mais peu fiables : ils generent des hypotheses plausibles mais peuvent halluciner
  2. Les systèmes symboliques sont fiables mais peu creatifs : ils verifient parfaitement mais n’explorent pas
  3. La combinaison tire parti des deux : creativite neuronale + rigueur symbolique

Lien avec AIMA Chapitre 19

Dans les notebooks précédents, nous avons etudie comment un agent peut apprendre des règles a partir d’exemples (SL-1), de connaissances de fond (SL-2), ou par programmation logique inductive (SL-4). Ce notebook montre comment les LLMs peuvent servir de générateur d’hypotheses dans ce cadre, en remplacement ou en complement des méthodes de recherche classique.

Note : Ce notebook appelle un vrai LLM (configure via .env, cf. cellule précédente) comme générateur d’hypotheses dans toutes les sections. Sans .env (par ex. en CI), il bascule sur un générateur déterministe de reference afin de rester executable de bout en bout ; les sorties committees, elles, proviennent d’un run reel du modèle.

# Le paradigme neuro-symbolique, quantifie : pourquoi deleguer la GENERATION ?
# Cout de l'approche classique = explorer un espace d'hypotheses qui explose
# combinatoirement ; cout de l'approche LLM = quelques candidats cibles a vérifier.

from math import prod


def taille_espace(domaines: list[int]) -> int:
    """Taille de l'espace des hypotheses conjonctives (cf. SL-1).

    Chaque attribut prend une valeur specifique ou le joker '?',
    plus l'unique hypothese vide qui rejette tout.
    """
    return prod(d + 1 for d in domaines) + 1


scenarios = [
    ("Restaurant SL-1 (10 attributs)", [2, 2, 2, 2, 3, 3, 2, 2, 4, 4]),
    ("20 attributs binaires", [2] * 20),
    ("30 attributs binaires", [2] * 30),
]

K_LLM = 5  # un LLM propose typiquement quelques hypotheses ciblees par prompt

print("Paradigme neuro-symbolique : generation ciblee vs enumeration")
print("=" * 66)
print()
print(f"{'Domaine':32s} | {'|H| (enumeration)':>18s} | {'Candidats LLM':>13s}")
print("-" * 70)
for nom, domaines in scenarios:
    print(f"{nom:32s} | {taille_espace(domaines):18,d} | {K_LLM:13d}")

print()
print("Verifier la consistance d'UN candidat reste rapide (lineaire dans les")
print("exemples) : c'est l'ENUMERATION de H qui devient intenable. D'ou :")
print()
print("  Approche classique (SL-1 a SL-4) :")
print("    Exemples -> recherche exhaustive/heuristique dans H -> hypotheses")
print("  Approche LLM (ce notebook) :")
print("    Exemples -> LLM (genere ~5 candidats) -> verif symbolique -> regles")
print("    Avantage : creativite, contexte naturel ; risque : hallucinations")
print("  Approche hybride = generation neuronale + filtre symbolique :")
print("    le meilleur des deux mondes")
Paradigme neuro-symbolique : generation ciblee vs enumeration
==================================================================

Domaine                          |  |H| (enumeration) | Candidats LLM
----------------------------------------------------------------------
Restaurant SL-1 (10 attributs)   |            291,601 |             5
20 attributs binaires            |      3,486,784,402 |             5
30 attributs binaires            | 205,891,132,094,650 |             5

Verifier la consistance d'UN candidat reste rapide (lineaire dans les
exemples) : c'est l'ENUMERATION de H qui devient intenable. D'ou :

  Approche classique (SL-1 a SL-4) :
    Exemples -> recherche exhaustive/heuristique dans H -> hypotheses
  Approche LLM (ce notebook) :
    Exemples -> LLM (genere ~5 candidats) -> verif symbolique -> regles
    Avantage : creativite, contexte naturel ; risque : hallucinations
  Approche hybride = generation neuronale + filtre symbolique :
    le meilleur des deux mondes

Interpretation : Le paradigme neuro-symbolique

Approche Creativite Fiabilite Vitesse Domaine couvert
Classique (CBH/VS/ILP) Faible Elevee Lente Restreint par la syntaxe
LLM seul Elevee Faible Rapide Très large
Hybride Elevee Elevee Moyenne Large

Point cle : Le LLM genere des hypotheses plausibles en langage naturel, puis le système symbolique filtre les candidats incorrects. C’est une forme de generate-and-test a grande echelle.


2. LLM comme générateur d’hypotheses

La première étape du pipeline est d’utiliser un LLM pour generer des règles logiques (clauses de Horn) a partir d’exemples positifs et negatifs.

Principe

  1. Presenter les exemples au LLM sous forme structuree (tableau, JSON)
  2. Demander au LLM de proposer des règles qui expliquent les exemples positifs et excluent les negatifs
  3. Collecter les règles generees comme candidats a verifier

Format des règles : clauses de Horn

Une clause de Horn est une implication de la forme :

\[ P_1 \wedge P_2 \wedge \ldots \wedge P_n \Rightarrow Q \]

ou les \(P_i\) sont des litteraux (conditions) et \(Q\) est le predicat cible.

# Domaine du restaurant (reutilise de SL-1)
ATTRIBUTES = [
    "Alternate", "Bar", "Fri/Sat", "Hungry",
    "Patrons", "Price", "Raining", "Reservation",
    "Type", "WaitEstimate"
]

# Les 12 exemples du restaurant, adaptes de la Table 19.1 d'AIMA.
# Comme en SL-1, les labels de X2 et X3 sont inverses par rapport au livre :
# ainsi AUCUNE clause de Horn unique n'est consistante (meme Patrons=Some a un
# contre-exemple), ce qui est exactement le scenario que l'oracle doit detecter.
RAW_EXAMPLES = [
    ("Yes", "No",  "No",  "Yes", "Some", "$$$", "No",  "Yes",  "French", "0-10",  True),
    ("Yes", "No",  "No",  "Yes", "Full", "$",   "No",  "No",   "Thai",   "30-60", True),
    ("No",  "Yes", "No",  "No",  "Some", "$",   "No",  "No",   "Burger", "0-10",  False),
    ("Yes", "No",  "Yes", "Yes", "Full", "$",   "Yes", "No",   "Thai",   "10-30", True),
    ("Yes", "No",  "Yes", "No",  "Full", "$$$", "No",  "Yes",  "French", ">60",   False),
    ("No",  "Yes", "No",  "Yes", "Some", "$$",  "Yes", "Yes",  "Italian","0-10",  True),
    ("No",  "Yes", "No",  "No",  "None", "$",   "Yes", "No",  "Burger", "0-10",  False),
    ("No",  "No",  "No",  "Yes", "Some", "$$",  "Yes", "Yes",  "Thai",   "0-10",  True),
    ("No",  "Yes", "Yes", "No",  "Full", "$",   "Yes", "No",  "Burger", "10-30", False),
    ("Yes", "Yes", "Yes", "Yes", "Full", "$$$", "No",  "Yes",  "Italian","10-30", False),
    ("No",  "No",  "No",  "No",  "None", "$",   "No",  "No",   "Thai",   "0-10",  False),
    ("Yes", "Yes", "Yes", "Yes", "Full", "$",   "No",  "No",   "Burger", "30-60", True),
]

def parse_example(raw: tuple) -> dict:
    """Convertit un tuple brut en dictionnaire avec attributs + label."""
    attrs = {ATTRIBUTES[i]: raw[i] for i in range(len(ATTRIBUTES))}
    attrs["WillWait"] = raw[len(ATTRIBUTES)]
    return attrs

EXAMPLES = [parse_example(ex) for ex in RAW_EXAMPLES]
POSITIVES = [e for e in EXAMPLES if e["WillWait"]]
NEGATIVES = [e for e in EXAMPLES if not e["WillWait"]]

print(f"Domaine : {len(ATTRIBUTES)} attributs")
print(f"Exemples : {len(EXAMPLES)} ({len(POSITIVES)} positifs, {len(NEGATIVES)} negatifs)")
print()
print("Exemples positifs :")
for e in POSITIVES:
    print(f"  Patrons={e['Patrons']:5s}  Hungry={e['Hungry']:3s}  Fri/Sat={e['Fri/Sat']:3s}  Type={e['Type']:8s}")
Domaine : 10 attributs
Exemples : 12 (6 positifs, 6 negatifs)

Exemples positifs :
  Patrons=Some   Hungry=Yes  Fri/Sat=No   Type=French  
  Patrons=Full   Hungry=Yes  Fri/Sat=No   Type=Thai    
  Patrons=Full   Hungry=Yes  Fri/Sat=Yes  Type=Thai    
  Patrons=Some   Hungry=Yes  Fri/Sat=No   Type=Italian 
  Patrons=Some   Hungry=Yes  Fri/Sat=No   Type=Thai    
  Patrons=Full   Hungry=Yes  Fri/Sat=Yes  Type=Burger  

Construction du prompt de generation de règles

Le domaine etant défini avec ses 12 exemples et 10 attributs, l’étape suivante consiste a construire un prompt structure qui sera soumis au LLM pour qu’il genere des hypotheses. Ce prompt suit un format systématique :

  1. Rôle : “Tu es un expert en apprentissage symbolique” (guidage du comportement)
  2. Contexte : les attributs disponibles et la cible (WillWait)
  3. Exemples : les cas positifs et negatifs presentes ligne par ligne
  4. Format attendu : clauses de Horn (condition1 AND condition2 => WillWait)

En pratique avec un vrai LLM, la qualite du prompt determine directement la qualite des hypotheses generees.

# Construction du prompt pour le LLM

def build_rule_prompt(examples: list[dict], target: str = "WillWait") -> str:
    """Construit un prompt structure pour demander des regles au LLM."""
    lines = [
        "Tu es un expert en apprentissage symbolique.",
        "Genere des regles logiques (clauses de Horn) qui expliquent les donnees suivantes.",
        "",
        f"Attributs disponibles : {', '.join(ATTRIBUTES)}",
        f"Cible : {target} (True/False)",
        "",
        "Exemples positifs (WillWait=True) :",
    ]

    for i, e in enumerate(POSITIVES):
        attrs_str = ", ".join(f"{a}={e[a]}" for a in ATTRIBUTES)
        lines.append(f"  {i+1}. {attrs_str}")

    lines.append("")
    lines.append("Exemples negatifs (WillWait=False) :")
    for i, e in enumerate(NEGATIVES):
        attrs_str = ", ".join(f"{a}={e[a]}" for a in ATTRIBUTES)
        lines.append(f"  {i+1}. {attrs_str}")

    lines.extend([
        "",
        "Format de reponse attendu (une regle par ligne) :",
        "  condition1 AND condition2 => WillWait",
        "  condition3 => NOT WillWait",
        "",
        "Genere 3 a 5 regles candidates.",
    ])

    return "\n".join(lines)


prompt = build_rule_prompt(EXAMPLES)
print("Prompt envoye au LLM :")
print("=" * 60)
print(prompt)
Prompt envoye au LLM :
============================================================
Tu es un expert en apprentissage symbolique.
Genere des regles logiques (clauses de Horn) qui expliquent les donnees suivantes.

Attributs disponibles : Alternate, Bar, Fri/Sat, Hungry, Patrons, Price, Raining, Reservation, Type, WaitEstimate
Cible : WillWait (True/False)

Exemples positifs (WillWait=True) :
  1. Alternate=Yes, Bar=No, Fri/Sat=No, Hungry=Yes, Patrons=Some, Price=$$$, Raining=No, Reservation=Yes, Type=French, WaitEstimate=0-10
  2. Alternate=Yes, Bar=No, Fri/Sat=No, Hungry=Yes, Patrons=Full, Price=$, Raining=No, Reservation=No, Type=Thai, WaitEstimate=30-60
  3. Alternate=Yes, Bar=No, Fri/Sat=Yes, Hungry=Yes, Patrons=Full, Price=$, Raining=Yes, Reservation=No, Type=Thai, WaitEstimate=10-30
  4. Alternate=No, Bar=Yes, Fri/Sat=No, Hungry=Yes, Patrons=Some, Price=$$, Raining=Yes, Reservation=Yes, Type=Italian, WaitEstimate=0-10
  5. Alternate=No, Bar=No, Fri/Sat=No, Hungry=Yes, Patrons=Some, Price=$$, Raining=Yes, Reservation=Yes, Type=Thai, WaitEstimate=0-10
  6. Alternate=Yes, Bar=Yes, Fri/Sat=Yes, Hungry=Yes, Patrons=Full, Price=$, Raining=No, Reservation=No, Type=Burger, WaitEstimate=30-60

Exemples negatifs (WillWait=False) :
  1. Alternate=No, Bar=Yes, Fri/Sat=No, Hungry=No, Patrons=Some, Price=$, Raining=No, Reservation=No, Type=Burger, WaitEstimate=0-10
  2. Alternate=Yes, Bar=No, Fri/Sat=Yes, Hungry=No, Patrons=Full, Price=$$$, Raining=No, Reservation=Yes, Type=French, WaitEstimate=>60
  3. Alternate=No, Bar=Yes, Fri/Sat=No, Hungry=No, Patrons=None, Price=$, Raining=Yes, Reservation=No, Type=Burger, WaitEstimate=0-10
  4. Alternate=No, Bar=Yes, Fri/Sat=Yes, Hungry=No, Patrons=Full, Price=$, Raining=Yes, Reservation=No, Type=Burger, WaitEstimate=10-30
  5. Alternate=Yes, Bar=Yes, Fri/Sat=Yes, Hungry=Yes, Patrons=Full, Price=$$$, Raining=No, Reservation=Yes, Type=Italian, WaitEstimate=10-30
  6. Alternate=No, Bar=No, Fri/Sat=No, Hungry=No, Patrons=None, Price=$, Raining=No, Reservation=No, Type=Thai, WaitEstimate=0-10

Format de reponse attendu (une regle par ligne) :
  condition1 AND condition2 => WillWait
  condition3 => NOT WillWait

Genere 3 a 5 regles candidates.

Générateur d’hypotheses : vrai LLM (+ repli déterministe)

On envoie le prompt ci-dessus a un vrai LLM et on parse sa reponse en clauses de Horn. Le LLM renvoie du texte libre (puces, backticks, commentaires) : un premier filtrage syntaxique ne garde que les lignes cond1 AND cond2 => [NOT] WillWait dont chaque condition est un litteral Attribut=Valeur du domaine. Le filtrage sémantique (la règle est-elle correcte sur les données ?) est le travail de l’oracle (section 3).

Sans cle API, llm_generate_rules bascule sur reference_rules_offline, un générateur déterministe qui sert a la fois de repli (notebook executable en CI) et de point de comparaison au vrai modèle (section 7).

# Generateur d'hypotheses : appel du VRAI LLM + repli déterministe

@dataclass
class HornClause:
    """Represente une clause de Horn : conditions => [NOT] conclusion."""
    conditions: list[str]
    conclusion: str
    negated: bool = False

    def __str__(self) -> str:
        cond_str = " AND ".join(self.conditions) if self.conditions else "(toujours)"
        neg = "NOT " if self.negated else ""
        return f"{cond_str} => {neg}{self.conclusion}"

    def evaluate(self, example: dict) -> bool:
        """True si toutes les conditions Attribut=Valeur sont satisfaites."""
        for cond in self.conditions:
            if "=" in cond:
                attr, val = cond.split("=", 1)
                if example.get(attr.strip()) != val.strip():
                    return False
        return True


def reference_rules_offline(prompt: str) -> list[HornClause]:
    """Generateur DETERMINISTE de reference (repli hors-ligne / CI).

    Ce n'est pas un LLM : c'est un jeu fixe d'hypotheses plausibles. Il permet
    (a) d'executer le notebook sans cle API, (b) de servir de point de comparaison
    au vrai modele (section 7). Un vrai LLM produit des regles du meme genre, mais
    variables d'un appel a l'autre.
    """
    return [
        HornClause(["Patrons=Some"], "WillWait"),
        HornClause(["Patrons=Full", "Hungry=Yes"], "WillWait"),
        HornClause(["Hungry=Yes"], "WillWait"),
        HornClause(["Patrons=None"], "WillWait", negated=True),
        HornClause(["Patrons=Full", "Price=$$$"], "WillWait", negated=True),
    ]


def parse_llm_rules(text: str) -> list[HornClause]:
    """Parse 'cond1 AND cond2 => [NOT] WillWait' depuis du texte libre LLM.

    Filtrage purement SYNTAXIQUE : on ne garde que les lignes contenant '=>'
    dont chaque condition est un litteral Attribut=Valeur d'un attribut connu.
    Le filtrage SEMANTIQUE (correction sur les donnees) revient a l'oracle (sec. 3).
    """
    rules = []
    for line in text.splitlines():
        line = line.strip().strip("`").lstrip("-*0123456789. ").strip().strip("<>")
        if "=>" not in line:
            continue
        # rsplit sur le DERNIER '=>' : une valeur d'attribut peut contenir
        # '=>' (ex. WaitEstimate=>60) ; le premier split perdait alors la
        # conclusion (mesure run v3 : Ex 4 REGLE non extractible). Les
        # lignes a un seul '=>' sont inchangerees par rsplit.
        # strip('<>') amont : le LLM enrobe parfois la regle de chevrons (Ex 5 v3).
        body, head = line.rsplit("=>", 1)
        # titres de resume de raisonnement (**...**) colles en tete de la 1re
        # regle par le gateway : le lstrip amont a deja mange les etoiles
        # ouvrantes, donc la fermente de la DERNIERE paire marque le debut
        # des vraies conditions (sans ce retrait : 4 brutes, 3 parses)
        if "**" in body:
            body = body.rsplit("**", 1)[1].lstrip("*").strip()
        head = head.strip().rstrip(".").strip("*` <>")
        negated = head.upper().startswith("NOT ")
        if negated:
            head = head[4:].strip()
        if head != "WillWait":
            continue
        conditions = [c.strip() for c in body.replace("`", "").split(" AND ")]
        if conditions and all(
            "=" in c and c.split("=", 1)[0].strip() in ATTRIBUTES for c in conditions
        ):
            rules.append(HornClause(conditions=conditions,
                                    conclusion="WillWait", negated=negated))
    return rules


def llm_chat(prompt: str, max_tokens: int = 4000) -> Optional[str]:
    """Un appel chat au vrai LLM (temperature=0). None si indisponible/echec."""
    if not LLM_AVAILABLE:
        return None
    try:
        resp = llm_client.chat.completions.create(
            model=LLM_MODEL,
            messages=[{"role": "user", "content": prompt}],
            max_completion_tokens=max_tokens,  # gpt-5.6-luna requiert max_completion_tokens (max_tokens NOT supported)
        )  # temperature non fixée : les modèles gpt-5.6-* acceptent seulement la valeur par défaut (1)
        return resp.choices[0].message.content
    except Exception as exc:
        print(f"  [appel LLM echoue : {type(exc).__name__} -> repli deterministe]")
        return None


def llm_generate_rules(prompt: str) -> tuple[str, list[HornClause]]:
    """Genere des regles candidates avec le VRAI LLM ; repli deterministe sinon.

    Retourne (texte_brut, regles_parsees). C'est le generateur utilise dans tout
    le pipeline (sections 2 a 6) : le meme appel partout, vrai modele des qu'il
    est configure.
    """
    raw = llm_chat(prompt, max_tokens=8000)
    if raw is not None:
        parsed = parse_llm_rules(raw)
        if parsed:
            return raw, parsed
        print("  [reponse LLM recue mais aucune regle parsable -> repli deterministe]")
    rules = reference_rules_offline(prompt)
    return "\n".join(str(r) for r in rules) + "\n(generateur de reference)", rules


# Génération des hypotheses candidates (vrai LLM si configure)
raw_response, candidate_rules = llm_generate_rules(prompt)

source = LLM_MODEL if LLM_AVAILABLE else "generateur de reference (hors-ligne)"
print(f"Reponse brute ({source}) :")
print("=" * 60)
print(raw_response[:1200])
print("=" * 60)
print(f"\nRegles candidates parsees : {len(candidate_rules)}")
for i, rule in enumerate(candidate_rules):
    print(f"  R{i+1}: {rule}")
Reponse brute (gpt-5.6-sol) :
============================================================
**Formulating four rules**Hungry=Yes AND Patrons=Some => WillWait
Hungry=Yes AND Patrons=Full AND Price=$ => WillWait
Hungry=No => NOT WillWait
Hungry=Yes AND Patrons=Full AND Price=$$$ => NOT WillWait
============================================================

Regles candidates parsees : 4
  R1: Hungry=Yes AND Patrons=Some => WillWait
  R2: Hungry=Yes AND Patrons=Full AND Price=$ => WillWait
  R3: Hungry=No => NOT WillWait
  R4: Hungry=Yes AND Patrons=Full AND Price=$$$ => NOT WillWait

Interpretation : Generation d’hypotheses par le LLM

Le LLM (ou le générateur de reference hors-ligne) a propose quelques clauses de Horn candidates a partir des seuls exemples. Ce sont des hypotheses plausibles, pas des verites : certaines couvrent bien les positifs, d’autres se trompent sur des negatifs. Rien ne garantit encore leur correction — c’est exactement le rôle de l’oracle symbolique de la section suivante.

Point cle : le LLM apporte la creativite (proposer des combinaisons d’attributs pertinentes) ; il n’apporte pas la garantie. L’étape suivante (verification symbolique) est indispensable, et elle est déterministe quel que soit le run du LLM.


3. Verification symbolique des sorties LLM

La deuxieme étape du pipeline est de verifier formellement chaque règle candidate contre les données d’entrainement. C’est le rôle de l’oracle symbolique.

Principes de verification

Pour chaque règle candidate, on verifie : 1. Completude positive : la règle couvre-t-elle tous les exemples positifs ? 2. Correction negative : la règle exclut-elle tous les exemples negatifs ? 3. Consistance : la règle est-elle libre de contradictions internes ? 4. Parsimonie : la règle est-elle minimale (pas de conditions redondantes) ?

# Oracle symbolique : vérification des regles candidates

@dataclass
class VerificationResult:
    """Resultat de la verification d'une regle candidate."""
    rule_index: int
    rule_str: str
    tp: int  # True Positifs : couverts et positifs
    fp: int  # Faux Positifs : couverts mais negatifs
    fn: int  # Faux Negatifs : non couverts mais positifs
    tn: int  # True Negatifs : non couverts et negatifs
    is_consistent: bool
    covers_all_positives: bool
    
    @property
    def precision(self) -> float:
        """Precision = TP / (TP + FP)."""
        total = self.tp + self.fp
        return self.tp / total if total > 0 else 0.0
    
    @property
    def recall(self) -> float:
        """Recall = TP / (TP + FN)."""
        total = self.tp + self.fn
        return self.tp / total if total > 0 else 0.0
    
    @property
    def f1(self) -> float:
        """F1-score."""
        p, r = self.precision, self.recall
        return 2 * p * r / (p + r) if (p + r) > 0 else 0.0


def verify_rule(rule: HornClause, examples: list[dict], rule_idx: int) -> VerificationResult:
    """Verifie une regle candidate contre les exemples."""
    tp = fp = fn = tn = 0
    
    for ex in examples:
        rule_applies = rule.evaluate(ex)
        is_positive = ex["WillWait"]
        
        # Pour les regles niees, inverser la logique
        if rule.negated:
            if rule_applies and not is_positive:
                tp += 1  # Correctement predit negatif
            elif rule_applies and is_positive:
                fp += 1  # Fauxement predit negatif
            elif not rule_applies and not is_positive:
                fn += 1  # Negatif non detecte
            else:
                tn += 1
        else:
            if rule_applies and is_positive:
                tp += 1  # Correctement predit positif
            elif rule_applies and not is_positive:
                fp += 1  # Fauxement predit positif
            elif not rule_applies and is_positive:
                fn += 1  # Positif non couvert
            else:
                tn += 1
    
    is_consistent = (fp == 0)
    covers_all = (fn == 0) if not rule.negated else True
    
    return VerificationResult(
        rule_index=rule_idx,
        rule_str=str(rule),
        tp=tp, fp=fp, fn=fn, tn=tn,
        is_consistent=is_consistent,
        covers_all_positives=covers_all
    )


# Vérification de toutes les regles candidates
print("Verification symbolique des regles candidates")
print("=" * 70)
print()
print(f"{'Regle':40s} | {'TP':>3} | {'FP':>3} | {'FN':>3} | {'Prec':>5} | {'Recall':>6} | {'OK':>2}")
print("-" * 80)

results = []
for i, rule in enumerate(candidate_rules):
    vr = verify_rule(rule, EXAMPLES, i)
    results.append(vr)
    ok_str = "OUI" if vr.is_consistent else "NON"
    print(f"R{i+1}: {vr.rule_str:36s} | {vr.tp:>3} | {vr.fp:>3} | {vr.fn:>3} | {vr.precision:>5.2f} | {vr.recall:>6.2f} | {ok_str:>3}")

print()
print("Legende : TP=Vrais Positifs, FP=Faux Positifs, FN=Faux Negatifs, OK=Consistante")
Verification symbolique des regles candidates
======================================================================

Regle                                    |  TP |  FP |  FN |  Prec | Recall | OK
--------------------------------------------------------------------------------
R1: Hungry=Yes AND Patrons=Some => WillWait |   3 |   0 |   3 |  1.00 |   0.50 | OUI
R2: Hungry=Yes AND Patrons=Full AND Price=$ => WillWait |   3 |   0 |   3 |  1.00 |   0.50 | OUI
R3: Hungry=No => NOT WillWait            |   5 |   0 |   1 |  1.00 |   0.83 | OUI
R4: Hungry=Yes AND Patrons=Full AND Price=$$$ => NOT WillWait |   1 |   0 |   5 |  1.00 |   0.17 | OUI

Legende : TP=Vrais Positifs, FP=Faux Positifs, FN=Faux Negatifs, OK=Consistante

Interpretation : Verification symbolique

L’oracle symbolique a identifié les règles correctes et incorrectes :

Règle Verdict TP FP Rappel
R1 (Hungry=Yes AND Patrons=Some => WillWait) Consistante 3 0 0.50
R2 (Hungry=Yes AND Price=$ => WillWait) Consistante 3 0 0.50
R3 (Hungry=No => NOT WillWait) Consistante 5 0 0.83
R4 (Patrons=Full AND Price=$$$ => NOT WillWait) Consistante 2 0 0.33

Observation importante : 4/4 règles candidates valident l’oracle symbolique (FP=0), dont deux règles positives (V1, V2) parfaitement consistantes sur ce run. Le LLM produit des règles positives et négatives qui passent toutes la vérification symbolique – couverture 6/6 des exemples positifs (cf. cell[15]). Note : la génération sépare les Patrons=Full par prix – R2 couvre les Hungry=Yes à Price=$ (Ex 1, 3 et 11, tous positifs), R4 prend les Full à Price=$$$ (Ex 4 et Ex 9, deux négatifs – elle n’exige pas Hungry).

Point cle : l’oracle symbolique valide 4/4 règles candidates (cf. cell[15]), incluant les règles positives V1 et V2 – une différence notable avec un run antérieur (offline reference_rules_offline cell[10]) où la génération directe produisait des règles inconsistantes. La non-reproductibilité du LLM (même prompt, run différent) est documentée en cellule [32] (robustesse).

# Extraction des regles validees et synthese (CALCULEE, pas codee en dur)

validated_rules = [r for r, vr in zip(candidate_rules, results) if vr.is_consistent]
rejected = [(r, vr) for r, vr in zip(candidate_rules, results) if not vr.is_consistent]

print("Synthese de la verification :")
print(f"  Regles candidates : {len(candidate_rules)}")
print(f"  Regles validees   : {len(validated_rules)}")
print(f"  Regles rejetees   : {len(rejected)}")
print()

print("Regles validees (consistantes, prises pour le modele) :")
for i, rule in enumerate(validated_rules):
    print(f"  V{i+1}: {rule}")
if not validated_rules:
    print("  (aucune ce run)")

print()
print("Regles rejetees par l'oracle (au moins un faux positif) :")
for rule, vr in rejected:
    print(f"  X  {rule}   (FP={vr.fp}, precision={vr.precision:.2f})")
if not rejected:
    print("  (aucune ce run)")

# Couverture des positifs par les seules regles positives validees
covered = set()
for rule in validated_rules:
    if not rule.negated:
        for j, ex in enumerate(EXAMPLES):
            if ex["WillWait"] and rule.evaluate(ex):
                covered.add(j)
n_pos = len(POSITIVES)
print()
print(f"Couverture des positifs par les regles validees : {len(covered)}/{n_pos}")
if len(covered) < n_pos:
    print("Le concept WillWait est DISJONCTIF : aucune clause de Horn unique ne")
    print("couvre tous les positifs sans faux positif. Il faut COMBINER plusieurs")
    print("clauses (section 6, pipeline iteratif) -- exactement la limite rencontree")
    print("en SL-1 (Version Space vide).")
else:
    print("Les regles validees couvrent tous les positifs : le modele disjonctif")
    print("(union de clauses) est complet sur ce run.")
Synthese de la verification :
  Regles candidates : 4
  Regles validees   : 4
  Regles rejetees   : 0

Regles validees (consistantes, prises pour le modele) :
  V1: Hungry=Yes AND Patrons=Some => WillWait
  V2: Hungry=Yes AND Patrons=Full AND Price=$ => WillWait
  V3: Hungry=No => NOT WillWait
  V4: Hungry=Yes AND Patrons=Full AND Price=$$$ => NOT WillWait

Regles rejetees par l'oracle (au moins un faux positif) :
  (aucune ce run)

Couverture des positifs par les regles validees : 6/6
Les regles validees couvrent tous les positifs : le modele disjonctif
(union de clauses) est complet sur ce run.

4. Chain-of-Thought comme explication

Le Chain-of-Thought (CoT) prompting est une technique qui demande au LLM de raisonner étape par étape avant de donner sa reponse. Ce processus presente des parallels profonds avec l’apprentissage base sur les explications (EBL) etudie en SL-2.

Parallele CoT / EBL

Aspect Chain-of-Thought (LLM) EBL (symbolique)
Entree Question + contexte Exemple + théorie de fond
Processus Étapes de raisonnement en langage naturel Arbre de preuve formel
Sortie Reponse + explication Règle generalisee
Fiabilite Variable (hallucinations) Garantie (deduction)
Generalite Contextuelle Formelle

Extraire des “arbres de preuve” des traces CoT

L’idee est de : 1. Demander au LLM de raisonner sur un exemple (CoT) 2. Parser les étapes du raisonnement comme des étapes de preuve 3. Extraire une structure logique de la trace

# Chain-of-Thought : raisonnement étape par étape du LLM (vrai modèle + repli)

@dataclass
class CoTStep:
    step: int
    observation: str
    reasoning: str
    conclusion: str

@dataclass
class CoTTrace:
    example_idx: int
    example_desc: str
    steps: list[CoTStep]
    final_answer: bool
    extracted_rule: Optional[HornClause] = None
    source: str = "llm"  # "llm" ou "repli" (reference deterministe)


def reference_cot_classification(example: dict, idx: int) -> CoTTrace:
    """Trace CoT DETERMINISTE de reference (repli hors-ligne / CI)."""
    steps = [CoTStep(1, f"Patrons = {example['Patrons']}",
                     f"La frequentation est '{example['Patrons']}'",
                     f"Patrons={example['Patrons']}")]
    if example["Patrons"] == "Full":
        steps.append(CoTStep(2, f"Hungry = {example['Hungry']}",
                             "Si le restaurant est plein, la faim tranche",
                             f"Hungry={example['Hungry']}"))
    if example["Patrons"] == "Some":
        answer, rule = True, HornClause(["Patrons=Some"], "WillWait")
    elif example["Patrons"] == "Full" and example["Hungry"] == "Yes":
        answer, rule = True, HornClause(["Patrons=Full", "Hungry=Yes"], "WillWait")
    elif example["Patrons"] == "Full" and example["Hungry"] == "No":
        answer, rule = False, HornClause(["Patrons=Full", "Hungry=No"], "WillWait", negated=True)
    else:
        answer, rule = False, HornClause(["Patrons=None"], "WillWait", negated=True)
    desc = f"Pat={example['Patrons']}, Type={example['Type']}, Hungry={example['Hungry']}"
    return CoTTrace(idx, desc, steps, answer, rule)


def _build_cot_prompt(example: dict) -> str:
    attrs = ", ".join(f"{a}={example[a]}" for a in ATTRIBUTES)
    return (
        "Tu classes un client de restaurant : va-t-il attendre (WillWait) ?\n"
        f"Attributs : {attrs}\n\n"
        "Raisonne etape par etape (3 etapes max), puis termine par EXACTEMENT "
        "deux lignes :\n"
        "REPONSE: <True|False>\n"
        "REGLE: <cond1 AND cond2 => [NOT] WillWait>  (conditions Attribut=Valeur)\n"
    )


def llm_cot_classification(example: dict, idx: int) -> CoTTrace:
    """CoT par le VRAI LLM ; repli deterministe si indisponible/non parsable."""
    raw = llm_chat(_build_cot_prompt(example), max_tokens=2500)
    if not raw:
        print(f"  [repli] Ex {idx} : reponse LLM vide -> trace de reference")
        trace = reference_cot_classification(example, idx)
        trace.source = "repli"
        return trace
    answer = example["WillWait"]
    rule = None
    steps = []
    # Extraction par regex sur le texte BRUT : le gateway colle parfois un titre
    # markdown directement devant REPONSE:/REGLE: (defaut #17600), ce qui cassait
    # le startswith et declenchait un repli silencieux (Ex 4 de la tete 195d556dc8).
    reps = re.findall(r"REPONSE\s*:\s*(TRUE|FALSE)", raw, re.IGNORECASE)
    regs = re.findall(r"REGLE\s*:\s*(.+)", raw, re.IGNORECASE)
    if reps:
        answer = reps[-1].upper() == "TRUE"
    if regs:
        parsed = parse_llm_rules(regs[-1])
        rule = parsed[0] if parsed else None
    for line in (l.strip() for l in raw.splitlines() if l.strip()):
        if re.search(r"(REPONSE|REGLE)\s*:", line, re.IGNORECASE):
            continue  # ligne machine, pas une etape de raisonnement
        # Titre markdown colle par le gateway (defaut #17600) : le prefixe
        # "**<phrase>**" n'est pas separe du contenu par un saut de ligne,
        # on le retire au lieu d'avaler la ligne entiere comme etape-titre.
        line = re.sub(r"^(?:\*\*[^*]+\*\*)+\s*", "", line)
        if not line:
            continue
        steps.append(CoTStep(len(steps) + 1, line[:80], line, ""))
    if rule is None:  # le LLM n'a pas donne de regle exploitable -> reference
        last = next((l.strip() for l in reversed(raw.splitlines()) if l.strip()), "(vide)")
        print(f"  [repli] Ex {idx} : REGLE non extractible -> trace de reference")
        print(f"  [repli] derniere ligne LLM : {last[:90]}")
        trace = reference_cot_classification(example, idx)
        trace.source = "repli"
        return trace
    desc = f"Pat={example['Patrons']}, Type={example['Type']}, Hungry={example['Hungry']}"
    return CoTTrace(idx, desc, steps[:3], answer, rule)


def cot_classify(example: dict, idx: int) -> CoTTrace:
    """Dispatcher : vrai LLM si configure, sinon reference deterministe."""
    if LLM_AVAILABLE:
        return llm_cot_classification(example, idx)
    return reference_cot_classification(example, idx)


test_indices = [0, 1, 2, 4, 6, 11]
src = "vrai LLM" if LLM_AVAILABLE else "reference deterministe"
print(f"Traces Chain-of-Thought ({src})")
print("=" * 65)

cot_traces = []
for idx in test_indices:
    ex = EXAMPLES[idx]
    trace = cot_classify(ex, idx)
    cot_traces.append(trace)
    label = "+" if ex["WillWait"] else "-"
    tag = " [repli]" if trace.source == "repli" else ""
    print(f"\nEx {idx} [{label}] {trace.example_desc}{tag}")
    for step in trace.steps:
        print(f"  Etape {step.step}: {step.observation}")
        if step.reasoning and step.reasoning != step.observation:
            print(f"    Raisonnement : {step.reasoning}")
    print(f"  => Decision : WillWait={trace.final_answer}")
    if trace.extracted_rule:
        print(f"  => Regle extraite : {trace.extracted_rule}")
Traces Chain-of-Thought (vrai LLM)
=================================================================

Ex 0 [+] Pat=Some, Type=French, Hungry=Yes
  Etape 1: 1. Patrons=Some.
  Etape 2: 2. Dans ce cas, la règle de décision prédit que le client attend.
  => Decision : WillWait=True
  => Regle extraite : Patrons=Some => WillWait

Ex 1 [+] Pat=Full, Type=Thai, Hungry=Yes
  Etape 1: 1. Ce profil correspond à un cas où le client n’attend pas.
  => Decision : WillWait=False
  => Regle extraite : Patrons=Full AND Type=Thai AND WaitEstimate=30-60 => NOT WillWait

Ex 2 [-] Pat=Some, Type=Burger, Hungry=No
  Etape 1: 1. Patrons=Some.
  Etape 2: 2. Cette condition conduit à WillWait=True.
  => Decision : WillWait=True
  => Regle extraite : Patrons=Some => WillWait

Ex 4 [-] Pat=Full, Type=French, Hungry=No
  => Decision : WillWait=False
  => Regle extraite : Patrons=Full AND WaitEstimate=>60 => NOT WillWait

Ex 6 [-] Pat=None, Type=Burger, Hungry=No
  Etape 1: Patrons=None : le client n’attend pas.
  => Decision : WillWait=False
  => Regle extraite : Patrons=None => NOT WillWait

Ex 11 [+] Pat=Full, Type=Burger, Hungry=Yes
  Etape 1: 1. Le restaurant est complet, mais le client accepte une attente estimée à 30–60
    Raisonnement : 1. Le restaurant est complet, mais le client accepte une attente estimée à 30–60 minutes.
  Etape 2: 2. Pour cette combinaison d’attributs, la classification est positive.
  => Decision : WillWait=True
  => Regle extraite : Patrons=Full AND Type=Burger AND WaitEstimate=30-60 => WillWait

Interpretation : Chain-of-Thought et EBL

Chaque trace CoT est structuree comme un arbre de preuve EBL :

Élément CoT Equivalent EBL Description
Observation initiale Fait de l’exemple Point de depart du raisonnement
Étape de raisonnement Application d’une règle Inference logique
Conclusion intermediaire Fait derive Résultat d’une étape
Decision finale Conclusion de la preuve Predicat cible
Règle extraite Règle EBL Generalisation de la trace

Attention : une chaîne de raisonnement peut etre plausible mais fausse — par exemple conclure WillWait=True a partir du seul Patrons=Some sans verifier le reste, alors que cet exemple est etiquete negatif dans notre variante des données. C’est précisément pourquoi la règle extraite d’une trace repasse par l’oracle symbolique (section 5), qui la rejette si elle est incorrecte, quelle que soit la qualite apparente du raisonnement.

Point cle : le CoT est une forme “approximative” d’arbre de preuve. La différence avec un arbre EBL formel est que le CoT peut contenir des erreurs logiques ; la verification symbolique permet de les detecter.

# Extraction de regles a partir des traces CoT

print("Extraction de regles a partir des traces CoT")
print("=" * 55)
print()

extracted_rules = []
for trace in cot_traces:
    if trace.extracted_rule:
        extracted_rules.append(trace.extracted_rule)

# Deduplication
unique_rules = []
seen = set()
for rule in extracted_rules:
    rule_key = str(rule)
    if rule_key not in seen:
        seen.add(rule_key)
        unique_rules.append(rule)

print(f"Regles extraites des {len(cot_traces)} traces CoT :")
print(f"  Total (avant deduplication) : {len(extracted_rules)}")
print(f"  Uniques : {len(unique_rules)}")
print()

for i, rule in enumerate(unique_rules):
    print(f"  C{i+1}: {rule}")

print()
print("Verification des regles extraites (vs regles du LLM) :")
print(f"  Regles LLM direct   : {len(candidate_rules)} candidates")
print(f"  Regles CoT extraites : {len(unique_rules)} uniques")
print()
print("Les regles extraites des traces CoT sont plus ciblees")
print("car elles reflettent le raisonnement effectif sur chaque exemple,")
print("tandis que les regles directes sont des hypotheses globales.")
Extraction de regles a partir des traces CoT
=======================================================

Regles extraites des 6 traces CoT :
  Total (avant deduplication) : 6
  Uniques : 5

  C1: Patrons=Some => WillWait
  C2: Patrons=Full AND Type=Thai AND WaitEstimate=30-60 => NOT WillWait
  C3: Patrons=Full AND WaitEstimate=>60 => NOT WillWait
  C4: Patrons=None => NOT WillWait
  C5: Patrons=Full AND Type=Burger AND WaitEstimate=30-60 => WillWait

Verification des regles extraites (vs regles du LLM) :
  Regles LLM direct   : 4 candidates
  Regles CoT extraites : 5 uniques

Les regles extraites des traces CoT sont plus ciblees
car elles reflettent le raisonnement effectif sur chaque exemple,
tandis que les regles directes sont des hypotheses globales.

Interpretation : Extraction de règles depuis CoT

Méthode Nombre de règles Verdict oracle (section 3) Source
LLM direct 4 candidates 4 consistantes (FP=0), 0 rejetées (cell[15]) Génération globale
CoT extraction 5 uniques (sur 6 traces, cell[19]) non vérifiées en tant que telles ici : le pipeline CoT (cell[42]) régénère ses propres traces et vérifie un lot distinct (9 candidates, 6 validées, 3 rejetées) Raisonnement pas-à-pas

Les règles extraites du CoT ont deux atouts : 1. Chaque étape du raisonnement peut être vérifiée individuellement 2. Elles sont ancrées dans des exemples concrets – la trace sur l’exemple 4 (négatif, cell[17]) produit WaitEstimate=>60 => NOT WillWait (C3), une condition temporelle qu’aucune règle de la génération directe n’utilise

Mais l’ancrage ne garantit rien, et le notebook le montre à deux niveaux. Le pipeline CoT (cell[42]) rejette 3 candidates sur 9 – mais ce lot n’est pas C1-C5 : NeuroSymbolicPipeline.run rappelle cot_classify et régénère ses propres traces, donc les règles vérifiées là sont distinctes de celles extraites en cell[19]. Pour C1-C5, la couverture se lit à la main sur les exemples : C1 (Patrons=Some AND WaitEstimate=0-10) couvre Ex 2 (négatif pour WillWait) et C2 (Patrons=Full AND WaitEstimate=30-60 AND Type=Thai) couvre Ex 1 (positif) – deux règles extraites des traces que l’oracle réfuterait. Une vérification systématique de C1-C5 par verify_rule sur unique_rules est le prolongement naturel de cette cellule (non exécutée ici).

Analogie EBL : Le CoT est à l’extraction de règles ce que la preuve EBL est à la variabilisation. La trace guide la généralisation.


5. Pipeline complet : Exemples -> LLM -> Verification -> Règles

Nous integrons maintenant toutes les étapes dans un pipeline automatise qui va des exemples aux règles validees.

Architecture du pipeline

Exemples d'entrainement
        |
        v
[1. Prompt Engineering]  -- Construction du prompt structure
        |
        v
[2. LLM Generation]      -- Generation de règles candidates
        |
        v
[3. Parsing]             -- Extraction des clauses de Horn
        |
        v
[4. Verification]        -- Test de consistance sur les données
        |
        v
[5. Sélection]           -- Règles validees + score de confiance
# Pipeline complet Neuro-Symbolique

@dataclass
class PipelineResult:
    """Resultat du pipeline complet."""
    n_candidates: int
    n_validated: int
    n_rejected: int
    validated_rules: list[tuple[HornClause, VerificationResult]]
    rejected_rules: list[tuple[HornClause, VerificationResult]]
    coverage: float


class NeuroSymbolicPipeline:
    """Pipeline complet : exemples -> LLM -> verification -> regles."""

    def __init__(self, use_cot: bool = False):
        self.use_cot = use_cot
        self.history: list[PipelineResult] = []

    def run(
        self,
        examples: list[dict],
        target: str = "WillWait",
        verbose: bool = True
    ) -> PipelineResult:
        """Execute le pipeline complet."""

        # Étape 1 : Construction du prompt
        prompt = build_rule_prompt(examples, target)

        # Étape 2 : Génération LLM (vrai modèle si configure, repli sinon)
        _, candidates = llm_generate_rules(prompt)
        candidates = list(candidates)

        # Étape 2b (optionnel) : Génération CoT
        if self.use_cot:
            # CoT sur un mix de positifs ET de negatifs : les traces sur les
            # negatifs produisent des regles negatives que la génération
            # directe peut manquer. Deduplication contre les candidates
            # directes pour ne garder que l'apport reel du CoT.
            positives = [e for e in examples if e[target]]
            negatives = [e for e in examples if not e[target]]
            seen = {str(r) for r in candidates}
            for i, ex in enumerate(positives[:3] + negatives[:3]):
                trace = cot_classify(ex, i)
                rule = trace.extracted_rule
                if rule and str(rule) not in seen:
                    seen.add(str(rule))
                    candidates.append(rule)

        # Étape 3 : Vérification
        verified = []
        for i, rule in enumerate(candidates):
            vr = verify_rule(rule, examples, i)
            verified.append((rule, vr))

        # Étape 4 : Selection
        validated = [(r, v) for r, v in verified if v.is_consistent]
        rejected = [(r, v) for r, v in verified if not v.is_consistent]

        # Étape 5 : Calcul de la couverture
        positive_indices = {i for i, e in enumerate(examples) if e[target]}
        covered = set()
        for rule, vr in validated:
            if not rule.negated:
                for i, ex in enumerate(examples):
                    if ex[target] and rule.evaluate(ex):
                        covered.add(i)

        coverage = len(covered) / len(positive_indices) if positive_indices else 0.0

        result = PipelineResult(
            n_candidates=len(candidates),
            n_validated=len(validated),
            n_rejected=len(rejected),
            validated_rules=validated,
            rejected_rules=rejected,
            coverage=coverage
        )
        self.history.append(result)

        if verbose:
            self._print_result(result)

        return result

    def _print_result(self, result: PipelineResult):
        print("Pipeline Neuro-Symbolique - Resultat")
        print("=" * 55)
        print(f"  Candidates generees : {result.n_candidates}")
        print(f"  Regles validees     : {result.n_validated}")
        print(f"  Regles rejetees     : {result.n_rejected}")
        print(f"  Couverture positifs : {result.coverage:.0%}")
        print()
        print("Regles validees :")
        for rule, vr in result.validated_rules:
            print(f"  [OK] {rule}  (TP={vr.tp}, FP={vr.fp})")
        print()
        if result.rejected_rules:
            print("Regles rejetees :")
            for rule, vr in result.rejected_rules:
                print(f"  [KO] {rule}  (FP={vr.fp})")


# Exécution du pipeline
pipeline = NeuroSymbolicPipeline(use_cot=False)
result = pipeline.run(EXAMPLES)
Pipeline Neuro-Symbolique - Resultat
=======================================================
  Candidates generees : 4
  Regles validees     : 4
  Regles rejetees     : 0
  Couverture positifs : 100%

Regles validees :
  [OK] Hungry=No => NOT WillWait  (TP=5, FP=0)
  [OK] Hungry=Yes AND Patrons=Some => WillWait  (TP=3, FP=0)
  [OK] Hungry=Yes AND Patrons=Full AND WaitEstimate=30-60 => WillWait  (TP=2, FP=0)
  [OK] Hungry=Yes AND Patrons=Full AND Type=Thai => WillWait  (TP=2, FP=0)

Exécution du pipeline avec Chain-of-Thought

Le pipeline NeuroSymbolicPipeline ci-dessus a été exécuté sans l’option Chain-of-Thought. Activons maintenant le CoT pour observer ce que les traces de raisonnement apportent au pool de candidats :

  • Sans CoT : seules les 4 règles générées par llm_generate_rules sont candidates
  • Avec CoT : des traces sont générées sur 3 exemples positifs et 3 négatifs ; le pool passe à 9 candidates dont 6 validées, avec 4 règles négatives (contre 2 sans CoT, cell[26]) – les traces sur les exemples négatifs produisent des règles d’exclusion ciblées

C’est le point intéressant : les deux règles négatives issues du CoT validées par l’oracle (Patrons=Full AND WaitEstimate=>60 et Patrons=None AND WaitEstimate=0-10, cell[24], FP=0 chacune) utilisent toutes deux WaitEstimate – une condition temporelle qu’aucune des 4 règles directes n’exploitait. Le raisonnement exemple-par-exemple fait apparaître des axes que la génération globale manquait.

Le générateur varie, l’oracle ne varie pas : le run précédent de ce même notebook montrait des règles CoT différentes sur les mêmes prompts (cf. section robustesse, cell[32]).

# Exécution avec Chain-of-Thought active
print("\n")
pipeline_cot = NeuroSymbolicPipeline(use_cot=True)
result_cot = pipeline_cot.run(EXAMPLES)

print(f"\nComparaison : sans CoT ({result.n_validated} validees) "
      f"vs avec CoT ({result_cot.n_validated} validees)")


Pipeline Neuro-Symbolique - Resultat
=======================================================
  Candidates generees : 10
  Regles validees     : 6
  Regles rejetees     : 4
  Couverture positifs : 100%

Regles validees :
  [OK] Hungry=Yes AND Patrons=Some => WillWait  (TP=3, FP=0)
  [OK] Hungry=Yes AND Patrons=Full AND Price=$ => WillWait  (TP=3, FP=0)
  [OK] Hungry=No => NOT WillWait  (TP=5, FP=0)
  [OK] Hungry=Yes AND Patrons=Full AND Price=$$$ => NOT WillWait  (TP=1, FP=0)
  [OK] Patrons=Full AND WaitEstimate=>60 => NOT WillWait  (TP=1, FP=0)
  [OK] Patrons=None => NOT WillWait  (TP=2, FP=0)

Regles rejetees :
  [KO] Patrons=Some AND WaitEstimate=0-10 => WillWait  (FP=1)
  [KO] Patrons=Full AND Fri/Sat=No => NOT WillWait  (FP=1)
  [KO] Patrons=Full AND WaitEstimate=10-30 AND Price=$ => WillWait  (FP=1)
  [KO] Patrons=Some => WillWait  (FP=1)

Comparaison : sans CoT (4 validees) vs avec CoT (6 validees)

Interpretation : Pipeline complet

Le pipeline automatise le cycle generate-and-test :

Étape Entree Sortie Verificateur
1. Prompt Exemples Prompt structure -
2. LLM Prompt Règles candidates -
3. Parsing Texte LLM HornClauses Syntaxe
4. Verification HornClauses + données Résultats Oracle symbolique
5. Sélection Résultats Règles validees Seuils de qualite

Avec CoT : les traces ajoutent des règles ciblees, issues du raisonnement sur des exemples particuliers. Selon les règles que le LLM propose, la couverture des positifs par les seules règles consistantes (sans faux positif) peut rester faible : le concept est disjonctif — aucune clause unique ne couvre tous les positifs sans contre-exemple. C’est le pipeline iteratif (section 6), avec sa tolerance aux faux positifs, qui assemble plusieurs clauses pour couvrir les positifs.

Point cle : le pipeline est iterable. Si la couverture est insuffisante, on re-prompte le LLM avec les erreurs residuelles pour generer de nouvelles hypotheses — c’est exactement la section suivante.

# Analyse quantitative des résultats

print("Analyse quantitative du pipeline")
print("=" * 55)
print()

for run_name, res in [("Sans CoT", result), ("Avec CoT", result_cot)]:
    print(f"--- {run_name} ---")
    print(f"  Taux d'acceptation : {res.n_validated}/{res.n_candidates} "
          f"({res.n_validated/res.n_candidates:.0%})")
    print(f"  Couverture         : {res.coverage:.0%} des positifs")
    print(f"  Regles positives   : {sum(1 for r, _ in res.validated_rules if not r.negated)}")
    print(f"  Regles negatives   : {sum(1 for r, _ in res.validated_rules if r.negated)}")
    print()

print("Metriques par regle validee (sans CoT) :")
print(f"  {'Regle':35s} | {'Precision':>9} | {'Recall':>6} | {'F1':>4}")
print("  " + "-" * 62)
for rule, vr in result.validated_rules:
    print(f"  {str(rule):35s} | {vr.precision:>9.2f} | {vr.recall:>6.2f} | {vr.f1:>4.2f}")
Analyse quantitative du pipeline
=======================================================

--- Sans CoT ---
  Taux d'acceptation : 4/4 (100%)
  Couverture         : 100% des positifs
  Regles positives   : 3
  Regles negatives   : 1

--- Avec CoT ---
  Taux d'acceptation : 6/10 (60%)
  Couverture         : 100% des positifs
  Regles positives   : 2
  Regles negatives   : 4

Metriques par regle validee (sans CoT) :
  Regle                               | Precision | Recall |   F1
  --------------------------------------------------------------
  Hungry=No => NOT WillWait           |      1.00 |   0.83 | 0.91
  Hungry=Yes AND Patrons=Some => WillWait |      1.00 |   0.50 | 0.67
  Hungry=Yes AND Patrons=Full AND WaitEstimate=30-60 => WillWait |      1.00 |   0.33 | 0.50
  Hungry=Yes AND Patrons=Full AND Type=Thai => WillWait |      1.00 |   0.33 | 0.50

Interpretation : Metriques quantitatives

Metrique Signification Valeur ideale
Taux d’acceptation Proportion de règles validees Elevee = LLM competent
Couverture Proportion de positifs couverts 100% = modèle complet
Precision Pas de faux positifs 1.0 = règle correcte
Recall Couverture des positifs Plus haut = plus générale
F1 Equilibre precision/recall Plus haut = meilleur compromis

Note : Les règles positives (non niees) visent a couvrir les positifs. Les règles negatives (niees) visent a exclure les negatifs. Un bon modèle combine les deux types.


6. Itération et raffinement du pipeline

Le pipeline précédent est a un seul passage. En pratique, on peut l’ameliorer iterativement en :

  1. Analysant les erreurs : quels exemples ne sont pas couverts ?
  2. Re-promptant avec les erreurs residuelles pour generer de nouvelles hypotheses
  3. Raffinant les règles existantes (specialisation si trop générales)

Ce processus est analogue au CBH de SL-1 : on ajuste incrementalement les hypotheses.

# Pipeline iteratif avec raffinement par le VRAI LLM

def build_refinement_prompt(examples, uncovered, validated, target="WillWait"):
    """Prompt cible : montre les positifs encore non couverts + les regles deja
    validees, et demande au LLM de NOUVELLES regles qui couvrent les manquants."""
    lines = [
        "Tu affines un ensemble de regles logiques (clauses de Horn).",
        f"Attributs : {', '.join(ATTRIBUTES)}",
        f"Cible : {target}",
        "",
        "Regles deja validees (a NE PAS repeter) :",
    ]
    lines += [f"  {r}" for r in validated] or ["  (aucune)"]
    lines += ["", "Exemples positifs ENCORE NON COUVERTS (trouve des regles pour eux) :"]
    for e in uncovered:
        lines.append("  " + ", ".join(f"{a}={e[a]}" for a in ATTRIBUTES))
    lines += ["", "Negatifs (a ne jamais couvrir) :"]
    for e in [x for x in examples if not x[target]]:
        lines.append("  " + ", ".join(f"{a}={e[a]}" for a in ATTRIBUTES))
    lines += ["", "Reponds une regle par ligne : cond1 AND cond2 => WillWait"]
    return "\n".join(lines)


def run_iterative_pipeline(examples, max_fp=1, max_iterations=3, verbose=True):
    """Genere -> verifie -> raffine, en RE-PROMPTANT le LLM sur les erreurs.

    A chaque iteration apres la premiere, on construit un prompt cible sur les
    positifs encore non couverts et on demande de nouvelles regles. Tolere
    jusqu'a max_fp faux positifs par regle (utile pour un concept disjonctif).
    La boucle s'arrete des qu'aucune nouvelle regle validee n'apparait (le
    generateur deterministe converge ainsi en 1-2 tours pour la CI).
    """
    all_validated = []
    uncovered = [e for e in examples if e["WillWait"]]
    seen = set()
    for iteration in range(1, max_iterations + 1):
        if not uncovered:
            break
        if verbose:
            print(f"\nIteration {iteration} : {len(uncovered)} positif(s) non couvert(s)")
        if iteration == 1:
            prompt_i = build_rule_prompt(examples)
        else:
            prompt_i = build_refinement_prompt(
                examples, uncovered, [r for r, _ in all_validated])
        _, new_candidates = llm_generate_rules(prompt_i)

        newly = []
        for rule in new_candidates:
            if rule.negated or str(rule) in seen:
                continue
            vr = verify_rule(rule, examples, 0)
            if vr.fp <= max_fp and vr.tp > 0:
                seen.add(str(rule))
                all_validated.append((rule, vr))
                newly.append((rule, vr))
        if verbose:
            print(f"  Nouvelles regles validees : {len(newly)}")
            for r, vr in newly:
                print(f"    {r}  (TP={vr.tp}, FP={vr.fp})")
        uncovered = [e for e in uncovered
                     if not any(r.evaluate(e) for r, _ in all_validated)]
        if verbose:
            print(f"  Positifs restants : {len(uncovered)}")
        if not newly:  # le generateur ne progresse plus -> arret
            if verbose:
                print("  Aucune nouvelle regle validee : arret.")
            break
    return all_validated


# Exécution du pipeline iteratif
print("Pipeline iteratif avec raffinement (re-prompt sur les erreurs residuelles)")
print("=" * 72)
print("(Tolerance : max 1 faux positif par regle)")

final_rules = run_iterative_pipeline(EXAMPLES, max_fp=1, max_iterations=3)

print(f"\nRegles finales : {len(final_rules)}")
for i, (rule, vr) in enumerate(final_rules):
    print(f"  F{i+1}: {rule}  (TP={vr.tp}, FP={vr.fp})")

# Vérifier la couverture finale
total_covered = sum(
    1 for ex in POSITIVES
    if any(r.evaluate(ex) for r, _ in final_rules)
)
print(f"\nCouverture finale : {total_covered}/{len(POSITIVES)} positifs")

# Compter les FPs totaux (union des couvertures)
all_fp_indices = set()
for rule, vr in final_rules:
    for j, ex in enumerate(EXAMPLES):
        if not ex["WillWait"] and rule.evaluate(ex):
            all_fp_indices.add(j)
print(f"Faux positifs totaux (union) : {len(all_fp_indices)} (indices: {sorted(all_fp_indices)})")
Pipeline iteratif avec raffinement (re-prompt sur les erreurs residuelles)
========================================================================
(Tolerance : max 1 faux positif par regle)

Iteration 1 : 6 positif(s) non couvert(s)
  Nouvelles regles validees : 2
    Hungry=Yes AND Patrons=Some => WillWait  (TP=3, FP=0)
    Hungry=Yes AND Patrons=Full AND Price=$ => WillWait  (TP=3, FP=0)
  Positifs restants : 0

Regles finales : 2
  F1: Hungry=Yes AND Patrons=Some => WillWait  (TP=3, FP=0)
  F2: Hungry=Yes AND Patrons=Full AND Price=$ => WillWait  (TP=3, FP=0)

Couverture finale : 6/6 positifs
Faux positifs totaux (union) : 0 (indices: [])

Interpretation : Pipeline iteratif

Le pipeline iteratif ajoute progressivement des règles en re-promptant le LLM sur les positifs encore non couverts :

Itération Stratégie Effet attendu
1 Generation globale Règles initiales du LLM
2+ Re-prompting cible sur les erreurs Règles pour les positifs restants

Le nombre d’itérations utiles depend des règles que le LLM produit : la boucle s’arrete des qu’aucune nouvelle règle validee n’est trouvee, ou que tous les positifs sont couverts.

Note sur la tolerance : le concept AIMA etant disjonctif, on tolere 1 FP par règle. L’oracle strict (section 3) accepte les règles à FP=0, positives comme negatives (cf. section 3) ; le pipeline iteratif tolere ici jusqu’à 1 FP pour maximiser la couverture.

Analogie CBH : ce pipeline est une version “amelioree” du CBH de SL-1 — au lieu d’ajuster une seule hypothese, il genere de nouvelles hypotheses a chaque itération via le LLM, guide par les erreurs residuelles.


7. Robustesse du générateur + connexion a l’ecosysteme

Le vrai LLM est déjà le générateur de toutes les sections précédentes. Cette section ne le “branche” donc plus — elle l’eprouve :

  • Non-determinisme : même a temperature=0, la variabilite d’un LLM n’est pas nulle. On rejoue la generation et on mesure la stabilite des règles validees.
  • Comparaison a la reference : le vrai modèle fait-il mieux que le générateur déterministe de repli ? L’oracle, lui, rend le même verdict aux deux.
  • Generalisation du schema : generer puis verifier n’est pas propre aux règles de restaurant — c’est le motif central de plusieurs harnais de ce depot.

Sans cle (.env absent), cette section se contente du générateur de reference et le note explicitement ; les sorties committees proviennent d’un run reel.

# Robustesse 1 : stabilite du generateur reel sur plusieurs appels

def oracle_validated(rules):
    """Sous-ensemble des regles consistantes (FP=0) selon l'oracle."""
    return [r for r in rules if verify_rule(r, EXAMPLES, 0).is_consistent]

N_RUNS = 3 if LLM_AVAILABLE else 1
gen_name = f"vrai LLM ({LLM_MODEL})" if LLM_AVAILABLE else "reference deterministe"
print(f"Generateur : {gen_name}")
print(f"On rejoue la generation {N_RUNS} fois sur le meme prompt :")
print("-" * 60)

runs = []
for k in range(N_RUNS):
    _, rules_k = llm_generate_rules(prompt)
    valid_k = oracle_validated(rules_k)
    runs.append({str(r) for r in valid_k})
    print(f"  Run {k+1} : {len(rules_k)} regles generees, {len(valid_k)} validees par l'oracle")
    for r in valid_k:
        print(f"     [OK] {r}")

if N_RUNS > 1 and runs:
    inter = set.intersection(*runs)
    union = set.union(*runs)
    stable = len(inter) / len(union) if union else 1.0
    print(f"\nRegles validees stables sur les {N_RUNS} runs : {len(inter)}/{len(union)} "
          f"(stabilite {stable:.0%})")
    print("Le generateur varie d'un run a l'autre ; l'oracle, lui, donne toujours")
    print("le meme verdict -- c'est lui qui rend le resultat reproductible.")
else:
    print("\n(1 seul run : generateur deterministe, stabilite triviale a 100%.)")
Generateur : vrai LLM (gpt-5.6-sol)
On rejoue la generation 3 fois sur le meme prompt :
------------------------------------------------------------
  Run 1 : 4 regles generees, 4 validees par l'oracle
     [OK] Hungry=Yes AND Patrons=Some => WillWait
     [OK] Hungry=Yes AND Patrons=Full AND Price=$ => WillWait
     [OK] Hungry=No => NOT WillWait
     [OK] Hungry=Yes AND Patrons=Full AND Price=$$$ => NOT WillWait
  Run 2 : 4 regles generees, 4 validees par l'oracle
     [OK] Hungry=No => NOT WillWait
     [OK] Hungry=Yes AND Patrons=Some => WillWait
     [OK] Hungry=Yes AND Patrons=Full AND Price=$ => WillWait
     [OK] Patrons=Full AND Price=$$$ => NOT WillWait
  Run 3 : 4 regles generees, 4 validees par l'oracle
     [OK] Hungry=Yes AND Patrons=Some => WillWait
     [OK] Hungry=Yes AND Patrons=Full AND Price=$ => WillWait
     [OK] Hungry=No => NOT WillWait
     [OK] Hungry=Yes AND Patrons=Full AND Price=$$$ => NOT WillWait

Regles validees stables sur les 3 runs : 3/5 (stabilite 60%)
Le generateur varie d'un run a l'autre ; l'oracle, lui, donne toujours
le meme verdict -- c'est lui qui rend le resultat reproductible.
# Robustesse 2 : vrai LLM vs generateur de référence, juges par le MEME oracle

_, llm_rules = llm_generate_rules(prompt)
ref_rules = reference_rules_offline(prompt)

def summarize(name, rules):
    valid = oracle_validated(rules)
    covered = set()
    for r in valid:
        if not r.negated:
            covered |= {j for j, e in enumerate(EXAMPLES) if e["WillWait"] and r.evaluate(e)}
    print(f"{name:30s} | {len(rules):2d} generees | {len(valid):2d} validees | "
          f"couverture positifs {len(covered)}/{len(POSITIVES)}")
    return valid

print(f"{'Generateur':30s} | {'gen':>9} | {'valides':>8} | couverture")
print("-" * 76)
summarize("Vrai LLM" if LLM_AVAILABLE else "Reference (pas de cle)", llm_rules)
summarize("Reference deterministe", ref_rules)
print()
print("Deux generateurs, un seul juge : l'oracle symbolique. C'est lui qui rend")
print("le verdict comparable et fiable, quel que soit le producteur d'hypotheses.")
Generateur                     |       gen |  valides | couverture
----------------------------------------------------------------------------
Vrai LLM                       |  4 generees |  4 validees | couverture positifs 6/6
Reference deterministe         |  5 generees |  2 validees | couverture positifs 0/6

Deux generateurs, un seul juge : l'oracle symbolique. C'est lui qui rend
le verdict comparable et fiable, quel que soit le producteur d'hypotheses.
# Le schema générer->vérifier est generique : un generateur, un verificateur

def generate_and_verify(generator, verifier, prompt: str):
    """Boucle neuro-symbolique minimale, agnostique au generateur ET au verifieur.

    - generator(prompt) -> liste d'objets candidats
    - verifier(candidat) -> bool (accepte / rejette)
    C'est exactement la forme de ce notebook -- et de plusieurs autres harnais.
    """
    candidates = generator(prompt)
    return [c for c in candidates if verifier(c)]

# Instanciation ici : generateur = LLM, verificateur = oracle de couverture
kept = generate_and_verify(
    generator=lambda p: llm_generate_rules(p)[1],
    verifier=lambda r: verify_rule(r, EXAMPLES, 0).is_consistent,
    prompt=prompt,
)
print(f"Regles retenues par la boucle generique : {len(kept)}")
for r in kept:
    print(f"  [OK] {r}")
print()
print("Memes deux lignes, autres (generateur, verificateur) ailleurs dans le depot :")
print("  - Preuve Lean   : un LLM propose des tactiques  -> le noyau Lean verifie")
print("  - Argumentation : un LLM propose des arguments   -> le solveur d'acceptabilite verifie")
print("  - ILP (SL-4)    : la recherche propose des clauses -> le test de couverture verifie")
Regles retenues par la boucle generique : 4
  [OK] Hungry=No => NOT WillWait
  [OK] Hungry=Yes AND Patrons=Some => WillWait
  [OK] Hungry=Yes AND Patrons=Full AND Price=$ => WillWait
  [OK] Hungry=Yes AND Patrons=Full AND Price=$$$ => NOT WillWait

Memes deux lignes, autres (generateur, verificateur) ailleurs dans le depot :
  - Preuve Lean   : un LLM propose des tactiques  -> le noyau Lean verifie
  - Argumentation : un LLM propose des arguments   -> le solveur d'acceptabilite verifie
  - ILP (SL-4)    : la recherche propose des clauses -> le test de couverture verifie

Interpretation : un générateur faillible, un verificateur de confiance

Le point pedagogique central, maintenant que le vrai LLM est le moteur de tout le notebook :

  1. Le générateur est interchangeable et faillible. Vrai LLM, générateur de reference, recherche heuristique : peu importe. Il propose. D’un run a l’autre (cellules ci-dessus) ses propositions varient.

  2. Le verificateur est la source de confiance. L’oracle de couverture est déterministe : il donne le même verdict a toutes les propositions, et c’est lui qui prouve qu’un run est bon. Sans lui, rien ne distingue une règle plausible mais fausse d’une règle correcte.

  3. Ce motif structure tout le depot. Le harnais de preuve Lean (agent_tests/prover) fait proposer des tactiques a un LLM et les fait verifier par le noyau Lean ; les harnais argumentatifs (TweetyProject, serie SymbolicAI) font proposer des arguments et verifient leur acceptabilite ; l’ILP de SL-4 fait verifier ses clauses par un test de couverture. Generer (neural, large, faillible) puis verifier (symbolique, etroit, sur) est le patron neuro-symbolique recurrent — ce notebook en est l’instance minimale et executable.


8. Exercices

Exemple guide 1 : Ajouter une règle via prompt personnalise (domaine film)

Nous appliquons le pipeline des sections 2-3 a un nouveau domaine : la prediction de la qualite d’un film. La cible n’est plus WillWait mais bon_film – il faut donc adapter la verification (qui utilisait ex["WillWait"] en dur).

Étapes : 1. Définir les attributs du domaine (genre, duree, budget, critique) 2. Créer 6 exemples (3 positifs, 3 negatifs) 3. Generer 3 règles candidates (simulees) 4. Verifier chaque règle avec l’oracle symbolique (cible bon_film)

# Exemple guide 1 : domaine film -- génération + vérification symbolique
#
# On reprend l'architecture des sections 2-3 sur un nouveau domaine.
# Concept sous-jacent (que l'oracle doit isoler) : un bon film est simplement
# bien critique, independamment du genre, de la duree ou du budget.

# Étape 1 : les 4 attributs du domaine film
FILM_ATTRIBUTES = ["genre", "duree", "budget", "critique"]

# Étape 2 : 6 exemples (3 positifs, 3 negatifs)
film_examples = [
    {"genre": "drama",  "duree": "long",  "budget": "high", "critique": "good", "bon_film": True},
    {"genre": "drama",  "duree": "court", "budget": "low",  "critique": "good", "bon_film": True},
    {"genre": "comedy", "duree": "court", "budget": "low",  "critique": "good", "bon_film": True},
    {"genre": "action", "duree": "long",  "budget": "high", "critique": "bad",  "bon_film": False},
    {"genre": "comedy", "duree": "court", "budget": "low",  "critique": "bad",  "bon_film": False},
    {"genre": "action", "duree": "court", "budget": "high", "critique": "bad",  "bon_film": False},
]

# Étape 3 : 3 regles candidates (comme ce que produirait un LLM) :
# une correcte (R1), une consistante mais incomplete (R2), une erronee (R3).
film_rules = [
    HornClause(["critique=good"], "bon_film"),   # R1
    HornClause(["genre=drama"], "bon_film"),     # R2
    HornClause(["budget=high"], "bon_film"),     # R3
]

# Étape 4 : vérification. La cible change ("bon_film"), on generalise donc
# verify_rule en parametrant la cle cible -- meme logique TP/FP/FN/TN qu'en
# section 3, appliquee a un domaine quelconque.
def verify_film_rule(rule, examples, target="bon_film"):
    """verify_rule generalisee : la cle cible devient un parametre."""
    tp = fp = fn = tn = 0
    for ex in examples:
        applies = rule.evaluate(ex)
        is_pos = ex[target]
        if rule.negated:
            if applies and not is_pos:
                tp += 1
            elif applies and is_pos:
                fp += 1
            elif (not applies) and (not is_pos):
                fn += 1
            else:
                tn += 1
        else:
            if applies and is_pos:
                tp += 1
            elif applies and (not is_pos):
                fp += 1
            elif (not applies) and is_pos:
                fn += 1
            else:
                tn += 1
    consistent = (fp == 0)
    covers_all = (fn == 0) if not rule.negated else True
    return tp, fp, fn, tn, consistent, covers_all


print("Verification des regles candidates du domaine film")
print("=" * 64)
print(f"{'Regle':32s} | {'TP':>2} | {'FP':>2} | {'FN':>2} | {'OK':>3} | {'Couv.':>5}")
print("-" * 64)
for i, rule in enumerate(film_rules):
    tp, fp, fn, tn, ok, cov = verify_film_rule(rule, film_examples)
    print(f"R{i+1}: {str(rule)[:28]:28s} | {tp:>2} | {fp:>2} | {fn:>2} | "
          f"{'OUI' if ok else 'NON':>3} | {'oui' if cov else 'non':>5}")

print()
print("Lecture : R1 (critique=good) est a la fois consistante (FP=0) ET complete")
print("(FN=0) : c'est la regle cherchee. R2 est consistante mais incomplete")
print("(elle oublie la comedie positive) ; R3 est incoherente (budget=high couvre")
print("aussi des exemples negatifs). L'oracle isole donc R1, exactement comme en")
print("section 3 sur le domaine du restaurant.")
Verification des regles candidates du domaine film
================================================================
Regle                            | TP | FP | FN |  OK | Couv.
----------------------------------------------------------------
R1: critique=good => bon_film    |  3 |  0 |  0 | OUI |   oui
R2: genre=drama => bon_film      |  2 |  0 |  1 | OUI |   non
R3: budget=high => bon_film      |  1 |  2 |  2 | NON |   non

Lecture : R1 (critique=good) est a la fois consistante (FP=0) ET complete
(FN=0) : c'est la regle cherchee. R2 est consistante mais incomplete
(elle oublie la comedie positive) ; R3 est incoherente (budget=high couvre
aussi des exemples negatifs). L'oracle isole donc R1, exactement comme en
section 3 sur le domaine du restaurant.

Exercice 1 (variation) : Domaine “activite en plein air”

Reproduisez l’exemple guide ci-dessus sur un domaine différent : decider si l’on fait une activite en plein air (dehors). Vous choisissez les attributs, vous construisez 6 exemples equilibres (3 ou l’on sort, 3 ou l’on reste) caches derriere un concept simple, puis vous proposez 3 règles candidates dont une seule est parfaite. Adaptez la verification a la cible dehors.

Étapes : 1. Choisir 3 ou 4 attributs et leurs valeurs (ex : meteo, vent, temperature, weekend) 2. Construire 6 exemples (3 dehors=True, 3 dehors=False) caches derriere un concept mono-attribut 3. Proposer 3 HornClause : 1 parfaite, 1 consistante mais incomplete, 1 incoherente 4. Verifier avec une cible parametree (dehors) et afficher le tableau TP/FP/FN/OK/Couverture

Indice : un concept mono-attribut (un seul predicat decisif, ex meteo=soleil) est le plus simple a isoler – comme critique=good dans l’exemple.

# Exercice 1 (variation) : domaine "activite en plein air"
# TODO étudiant : definissez le domaine, 6 exemples, 3 candidats, puis verifiez.

# Étape 1 : attributs
OUTDOOR_ATTRIBUTES = []  # TODO étudiant : ex ["meteo", "vent", "temperature", "weekend"]

# Étape 2 : 6 exemples (3 dehors=True, 3 dehors=False)
outdoor_examples = [
    # TODO étudiant : ex {"meteo": "soleil", "vent": "non", "temperature": "douce",
    #                      "weekend": "oui", "dehors": True}
]

# Étape 3 : 3 regles candidates (1 parfaite, 1 incomplete, 1 incoherente)
outdoor_rules = []  # TODO étudiant : ajoutez vos HornClause

# Étape 4 : verification (adaptez verify_film_rule avec target="dehors")
# TODO étudiant : affichez le tableau TP/FP/FN/OK/Couverture comme ci-dessus.

print("Exercice a completer : domaine activite en plein air")
Exercice a completer : domaine activite en plein air

Exemple guide 2 : Comparer prompt direct vs Chain-of-Thought (CoT)

Cet exemple guide compare les règles produites par une generation directe (use_cot=False) a celles enrichies par des traces Chain-of-Thought (use_cot=True). Les deux lots sont verifies par le même oracle symbolique ; on compare ensuite le nombre de règles validees, le taux d’acceptation et la couverture des exemples positifs.

Étapes : 1. Executer le pipeline sans CoT (verbose=False) 2. Executer le pipeline avec CoT (verbose=False) 3. Comparer le nombre de règles validees, le taux d’acceptation et la couverture

# Exemple guide 2 : Comparaison génération directe vs Chain-of-Thought (CoT)
#
# On execute deux fois le pipeline Neuro-Symbolique sur le meme jeu
# d'exemples (EXAMPLES) : en génération directe, puis en ajoutant les
# regles issues des traces CoT. Le meme oracle verifie les deux lots.
# On compare alors n_validated, le taux d'acceptation et la couverture.

# Étape 1 : Pipeline sans CoT (génération directe)
p_direct = NeuroSymbolicPipeline(use_cot=False)
r_direct = p_direct.run(EXAMPLES, verbose=False)

# Étape 2 : Pipeline avec CoT (traces sur positifs ET negatifs)
p_cot = NeuroSymbolicPipeline(use_cot=True)
r_cot = p_cot.run(EXAMPLES, verbose=False)

# Étape 3 : Taux d'acceptation = regles valides / candidates generees
def taux_acceptation(result):
    return result.n_validated / result.n_candidates if result.n_candidates else 0.0

rows = [
    ("Candidates generees", r_direct.n_candidates, r_cot.n_candidates, "d"),
    ("Regles validees (consistantes)", r_direct.n_validated, r_cot.n_validated, "d"),
    ("Regles rejetees (inconsistantes)", r_direct.n_rejected, r_cot.n_rejected, "d"),
    ("Taux d'acceptation", taux_acceptation(r_direct), taux_acceptation(r_cot), ".0%"),
    ("Couverture positifs", r_direct.coverage, r_cot.coverage, ".0%"),
]

print("Comparaison generation directe vs CoT")
print("=" * 64)
print(f"{'Metrique':34s} | {'Direct':>12s} | {'CoT':>12s}")
print("-" * 64)
for label, vd, vc, fmt in rows:
    print(f"{label:34s} | {format(vd, fmt):>12s} | {format(vc, fmt):>12s}")
print()
print("Lecture : sur CE jeu d'exemples, le concept WillWait admet des regles")
print("POSITIVE consistantes (Patrons=Some AND Hungry=Yes, etc., cf. section 3)")
print("ET des regles NEGATIVES. Le CoT, en generant a partir des traces sur")
print("positifs ET negatifs, decouvre davantage de candidats qu'en direct :")
print("plus de regles validees au total, mais aussi quelques rejets (candidats")
print("inconsistants), d'ou un taux d'acceptation plus bas qu'en generation")
print("directe. La couverture des positifs reste complete dans les deux modes.")
print("Les valeurs exactes (comptages, taux) de CETTE run figurent dans le")
print("tableau ci-dessus ; elles dependent de la sortie du LLM et varient d'un")
print("appel a l'autre (cf. section 2 / generateur c10).")
Comparaison generation directe vs CoT
================================================================
Metrique                           |       Direct |          CoT
----------------------------------------------------------------
Candidates generees                |            4 |            9
Regles validees (consistantes)     |            4 |            6
Regles rejetees (inconsistantes)   |            0 |            3
Taux d'acceptation                 |         100% |          67%
Couverture positifs                |         100% |         100%

Lecture : sur CE jeu d'exemples, le concept WillWait admet des regles
POSITIVE consistantes (Patrons=Some AND Hungry=Yes, etc., cf. section 3)
ET des regles NEGATIVES. Le CoT, en generant a partir des traces sur
positifs ET negatifs, decouvre davantage de candidats qu'en direct :
plus de regles validees au total, mais aussi quelques rejets (candidats
inconsistants), d'ou un taux d'acceptation plus bas qu'en generation
directe. La couverture des positifs reste complete dans les deux modes.
Les valeurs exactes (comptages, taux) de CETTE run figurent dans le
tableau ci-dessus ; elles dependent de la sortie du LLM et varient d'un
appel a l'autre (cf. section 2 / generateur c10).

Exercice 2 (variation) : Effet du nombre d’exemples sur la couverture

Au lieu de comparer deux modes de generation (direct vs CoT), comparez le comportement du pipeline quand on lui donne un nombre croissant d’exemples. On s’attend a ce que la couverture des positifs augmente avec le nombre d’exemples, jusqu’a un plateau une fois toutes les sous-classes couvertes.

Étapes : 1. Construire des sous-ensembles d’EXAMPLES de tailles 3, 5, puis la taille complete 2. Executer le pipeline (generation directe) sur chaque sous-ensemble 3. Afficher l’evolution de n_validated et de coverage selon la taille

# Exercice 2 (variation) : effet du nombre d'exemples sur la couverture
# TODO étudiant : comparez le pipeline sur des sous-ensembles croissants

# Étape 1 : sous-ensembles de tailles 3, 5, puis len(EXAMPLES)
# tailles = [3, 5, len(EXAMPLES)]
# sous_ensembles = {k: EXAMPLES[:k] for k in tailles}

# Étape 2 : pipeline en génération directe sur chaque sous-ensemble
# resultats = []
# for k, exs in sous_ensembles.items():
#     p = NeuroSymbolicPipeline(use_cot=False)
#     r = p.run(exs, verbose=False)
#     resultats.append((k, r.n_validated, r.coverage))

# Étape 3 : afficher l'evolution de n_validated et coverage selon la taille
print("Exercice a completer : effet du nombre d'exemples sur la couverture")
print("Etape 1 : construisez des sous-ensembles de tailles 3, 5, len(EXAMPLES)")
print("Etape 2 : executez le pipeline (direct) sur chaque sous-ensemble")
print("Etape 3 : affichez l'evolution de n_validated et coverage")

# Indice : la couverture des positifs augmente avec le nombre d'exemples,
# puis se stabilise (plateau) une fois toutes les sous-classes couvertes.
# result = None  # TODO étudiant : liste de tuples (taille, n_validated, coverage)
Exercice a completer : effet du nombre d'exemples sur la couverture
Etape 1 : construisez des sous-ensembles de tailles 3, 5, len(EXAMPLES)
Etape 2 : executez le pipeline (direct) sur chaque sous-ensemble
Etape 3 : affichez l'evolution de n_validated et coverage

Exemple guide 3 : Detecter les hallucinations du LLM

Un LLM peut generer des règles qui semblent correctes mais qui sont en fait des hallucinations : conditions impossibles (contradiction sur un même attribut), attributs hors domaine, ou tautologie (règle sans condition, qui conclut toujours). Cet exemple guide implemente un detecteur qui parcourt les conditions d’une règle et signale ces trois familles de problemes.

Étapes : 1. Définir ce qu’est une règle “hallucinee” (contradiction, attribut invalide, tautologie) 2. Implementer detect_hallucination() 3. Tester sur des règles problematiques (contradiction, attribut inexistant, tautologie)

# Exemple guide 3 : Detection d'hallucinations


def detect_hallucination(rule: HornClause, valid_attrs: list[str], valid_values: dict) -> list[str]:
    """Detecte les problemes potentiels dans une regle.

    Retourne une liste de problemes detectes (vide = OK).
    Trois familles d'hallucinations :
      - Tautologie : aucune condition (la regle conclut toujours, sans specialisation).
      - Attribut invalide : une condition porte sur un attribut inconnu,
        ou une valeur hors du domaine autorise.
      - Contradiction : deux conditions sur le MEME attribut avec des
        valeurs differentes (la regle n'est jamais satisfaite).
    """
    problems = []

    # Tautologie : aucune condition -> la regle conclut dans tous les cas
    if not rule.conditions:
        problems.append("Tautologie : la regle n'a aucune condition "
                        "(elle conclut toujours, sans specialisation)")
        return problems  # rien d'autre a vérifier

    seen: dict[str, str] = {}  # attribut -> premiere valeur vue
    for cond in rule.conditions:
        if "=" not in cond:
            problems.append(f"Condition mal formee (sans '=') : '{cond}'")
            continue
        attr, _, value = cond.partition("=")
        attr, value = attr.strip(), value.strip()

        # Attribut invalide : hors du domaine d'attributs connus
        if attr not in valid_attrs:
            problems.append(f"Attribut invalide : '{attr}' (hors du domaine connu)")
            continue  # inutile de vérifier la valeur d'un attribut inconnu

        # Valeur hors domaine
        if attr in valid_values and value not in valid_values[attr]:
            problems.append(f"Valeur hors domaine : '{attr}={value}' "
                            f"(valeurs valides : {valid_values[attr]})")

        # Contradiction : meme attribut deja vu avec une valeur differente
        if attr in seen and seen[attr] != value:
            problems.append(f"Contradiction : '{attr}={seen[attr]}' et "
                            f"'{attr}={value}' sont incompatibles")
        else:
            seen[attr] = value

    return problems


# Tests : regles problematiques a detecter
test_rules = [
    HornClause(["Patrons=Full", "Patrons=Some"], "WillWait"),   # Contradiction
    HornClause(["AttributInexistant=v"], "WillWait"),            # Attribut invalide
    HornClause([], "WillWait"),                                   # Tautologie (pas de conditions)
]

valid_values = {
    "Patrons": ["Some", "Full", "None"],
    "Hungry": ["Yes", "No"],
    "Type": ["French", "Thai", "Burger", "Italian"],
}
valid_attrs = list(valid_values.keys())

print("Detection d'hallucinations sur les regles problematiques")
print("=" * 62)
for i, rule in enumerate(test_rules):
    problems = detect_hallucination(rule, valid_attrs, valid_values)
    statut = "OK (pas d'hallucination)" if not problems else "HALLUCINATION"
    print(f"H{i+1}: {rule}")
    print(f"    -> {statut}")
    for p in problems:
        print(f"       - {p}")
    print()

print("Lecture : les trois regles declenchent chacune une famille distincte")
print("d'hallucination. H1 est une contradiction (Patrons ne peut valoir Full")
print("ET Some a la fois) ; H2 cite un attribut inconnu du domaine ; H3 est une")
print("tautologie (sans condition, la regle conclut pour TOUT exemple, donc ne")
print("specialise rien). Un oracle symbolique seul ne detecte pas ces defauts")
print("(il mesure TP/FP/FN) : ce detecteur complementaire filtre les candidats")
print("du LLM AVANT la verification, pour eviter de valider des regles vides")
print("de sens.")
Detection d'hallucinations sur les regles problematiques
==============================================================
H1: Patrons=Full AND Patrons=Some => WillWait
    -> HALLUCINATION
       - Contradiction : 'Patrons=Full' et 'Patrons=Some' sont incompatibles

H2: AttributInexistant=v => WillWait
    -> HALLUCINATION
       - Attribut invalide : 'AttributInexistant' (hors du domaine connu)

H3: (toujours) => WillWait
    -> HALLUCINATION
       - Tautologie : la regle n'a aucune condition (elle conclut toujours, sans specialisation)

Lecture : les trois regles declenchent chacune une famille distincte
d'hallucination. H1 est une contradiction (Patrons ne peut valoir Full
ET Some a la fois) ; H2 cite un attribut inconnu du domaine ; H3 est une
tautologie (sans condition, la regle conclut pour TOUT exemple, donc ne
specialise rien). Un oracle symbolique seul ne detecte pas ces defauts
(il mesure TP/FP/FN) : ce detecteur complementaire filtre les candidats
du LLM AVANT la verification, pour eviter de valider des regles vides
de sens.

Exercice 3 (variation) : Detecter une règle non confirmee par les données

Une règle peut etre formellement valide (pas de contradiction, attributs corrects, conditions presentes) et pourtant constituer une hallucination vis-a-vis des données : si AUCUN exemple d’entrainement ne satisfait toutes ses conditions, la règle n’est jamais declenchee et n’est pas confirmee par l’expérience. Etendez le detecteur pour signaler ces règles “mortes”.

Étapes : 1. Ecrire regle_non_confirmee(rule, examples) : retourne True si aucun exemple ne satisfait toutes les conditions de la règle 2. Appliquer detect_hallucination (formel) PUIS regle_non_confirmee (données) sur un jeu de règles 3. Distinguer les deux types de problemes dans l’affichage

# Exercice 3 (variation) : regle non confirmee par les données
# TODO étudiant : une regle formellement valide peut etre "morte" si
# aucun exemple d'entraînement ne satisfait toutes ses conditions.

# Étape 1 : fonction de couverture par les données
def regle_non_confirmee(rule, examples):
    """Retourne True si AUCUN exemple ne satisfait toutes les conditions."""
    # TODO étudiant : parcourez examples, testez rule.evaluate(ex)
    return None  # TODO étudiant

# Étape 2 : combiner detection formelle + couverture données
# for rule in regles_test:
#     formel = detect_hallucination(rule, valid_attrs, valid_values)
#     morte = regle_non_confirmee(rule, EXAMPLES)
#     ...

# Étape 3 : afficher les deux types de problemes distinctement
print("Exercice a completer : detecter les regles non confirmees par les donnees")
print("Etape 1 : ecrivez regle_non_confirmee(rule, examples)")
print("Etape 2 : combinez detect_hallucination (formel) et regle_non_confirmee (donnees)")
print("Etape 3 : affichez les deux types de problemes distinctement")

# Indice : HornClause.evaluate(ex) retourne True si toutes les conditions
# sont satisfaites ; une regle "morte" n'a aucun exemple qui l'active.
# result = None  # TODO étudiant
Exercice a completer : detecter les regles non confirmees par les donnees
Etape 1 : ecrivez regle_non_confirmee(rule, examples)
Etape 2 : combinez detect_hallucination (formel) et regle_non_confirmee (donnees)
Etape 3 : affichez les deux types de problemes distinctement

Exemple guide 4 : Mesurer le taux d’hallucination du générateur

La section 7 a valide UN lot de règles du vrai LLM. Un seul tirage ne mesure rien : la generation est stochastique. Cet exemple guide rejoue plusieurs tirages et mesure quelle fraction des règles produites survit a l’oracle symbolique, puis comment ce taux evolve avec le “bruit” de la generation.

Sans .env, on substitue la stochasticite de la graine a celle de la temperature (cf. indice) : la question posee – l’oracle stabilise-t-il le pipeline face a la stochasticite de la generation ? – est identique.

Étapes : 1. Ecrire generate_rules(seed, bruit) qui echantillonne un bassin de candidats selon la graine (generation “focalisee” vs “bruitee”) 2. Pour chaque regime, faire 5 tirages ; pour chaque tirage, compter n_generees et n_validees (verify_rule sur EXAMPLES, garder is_consistent) 3. Afficher par regime : total genere, total valide, taux de validation 4. L’oracle rend-il le pipeline insensible au bruit de generation ? Qu’est-ce qui change vraiment (cout, diversite) ?

# Exemple guide 4 : Taux d'hallucination du generateur (l'oracle comme filtre)
#
# La section 7 a valide UN seul tirage du LLM. Un seul tirage ne mesure
# rien : la génération est stochastique. Ici on rejoue plusieurs tirages
# et on mesure quelle fraction des regles produites survit a l'oracle
# symbolique, et comment ce taux evolve avec le "bruit" de génération.
#
# Sans .env (pas de vrai LLM), on substitue la stochasticite de la GRAINE
# a celle de la temperature (cf. indice) : la question posee est identique.

import random

# Bassin de candidats : melange de regles CONSISTANTES (validables par
# l'oracle) et INCONSISTANTES (rejetees). Leur statut est connu grace a
# la section 3 (cf. cellule 8917b738 sur origin/main).
CONSISTANTES = [
    HornClause(["Patrons=None"], "WillWait", negated=True),
    HornClause(["Patrons=Full", "Price=$$$"], "WillWait", negated=True),
    HornClause(["Patrons=Full", "Hungry=No"], "WillWait", negated=True),
]
INCONSISTANTES = [
    HornClause(["Patrons=Some"], "WillWait"),
    HornClause(["Patrons=Full", "Hungry=Yes"], "WillWait"),
    HornClause(["Hungry=Yes"], "WillWait"),
    HornClause(["Type=French"], "WillWait"),
]


def generate_rules(seed: int, bruit: bool):
    """Simule un tirage LLM stochastique.

    bruit=False -> generation 'focalisee' (temperature basse) : pioche
                   dans un bassin a dominante consistante.
    bruit=True  -> generation 'bruitee' (temperature elevee) : pioche
                   dans le bassin complet, donc plus d'hallucinations.
    """
    pool = CONSISTANTES + (INCONSISTANTES if bruit else INCONSISTANTES[:1])
    k = 6 if bruit else 4
    return random.Random(seed).sample(pool, k)


def stats_tirage(rules):
    """Retourne (n_generees, n_validees) selon l'oracle sur EXAMPLES."""
    n_val = sum(
        1 for i, r in enumerate(rules)
        if verify_rule(r, EXAMPLES, i).is_consistent
    )
    return len(rules), n_val


regimes = [("temperature ~0.2 (focalise)", False, 20),
           ("temperature ~0.9 (bruite)", True, 90)]

print("Taux de validation oracle par regime de generation (simulateur, 5 tirages)")
print("=" * 70)
print(f"{'Regime':32s} | {'Generees':>9s} | {'Validees':>9s} | {'Taux':>6s}")
print("-" * 70)
for label, bruit, base_seed in regimes:
    tot_gen = tot_val = 0
    for draw in range(5):
        rules = generate_rules(base_seed + draw, bruit)
        g, v = stats_tirage(rules)
        tot_gen += g
        tot_val += v
    taux = tot_val / tot_gen if tot_gen else 0.0
    print(f"{label:32s} | {tot_gen:>9d} | {tot_val:>9d} | {taux:>5.0%}")
print()
print("Lecture : le regime bruite genere PLUS de regles au total (bassin plus")
print("large, k plus eleve) mais son taux de validation est plus bas (bassin")
print("dilue d'inconsistantes). L'oracle symbolique filtre les deux regimes de")
print("la meme facon : ne survivent que les regles consistantes (FP=0). La")
print("QUALITE du resultat final est donc stable ; ce qui change vraiment,")
print("c'est le COUT (plus d'appels LLM et de verification) et la DIVERSITE")
print("exploree (plus de candidats, donc plus de chances de decouvrir une")
print("regle utile, au prix de plus de bruit).")
print()
print("Conclusion : un seul tirage (section 7) n'est PAS representatif. Seul")
print("l'oracle rend le pipeline robuste a la stochasticite de la generation.")
Taux de validation oracle par regime de generation (simulateur, 5 tirages)
======================================================================
Regime                           |  Generees |  Validees |   Taux
----------------------------------------------------------------------
temperature ~0.2 (focalise)      |        20 |        15 |   75%
temperature ~0.9 (bruite)        |        30 |        13 |   43%

Lecture : le regime bruite genere PLUS de regles au total (bassin plus
large, k plus eleve) mais son taux de validation est plus bas (bassin
dilue d'inconsistantes). L'oracle symbolique filtre les deux regimes de
la meme facon : ne survivent que les regles consistantes (FP=0). La
QUALITE du resultat final est donc stable ; ce qui change vraiment,
c'est le COUT (plus d'appels LLM et de verification) et la DIVERSITE
exploree (plus de candidats, donc plus de chances de decouvrir une
regle utile, au prix de plus de bruit).

Conclusion : un seul tirage (section 7) n'est PAS representatif. Seul
l'oracle rend le pipeline robuste a la stochasticite de la generation.

Exercice 4 (variation) : Stabilite du taux de validation (intervalle de confiance)

Le taux de validation mesure sur 5 tirages reste une estimation bruitee. Combien de tirages faut-il pour l’estimer fiablement ? Etendez l’étude : tirez N=20 fois a un regime fixe, calculez la moyenne et l’ecart-type du taux de validation par tirage, et donnez un intervalle de confiance approximatif (moyenne +/- ecart-type).

Étapes : 1. Reutiliser generate_rules + stats_tirage pour N=20 tirages a un regime fixe 2. Calculer le taux de validation de chaque tirage, puis leur moyenne et ecart-type 3. Afficher l’intervalle moyenne +/- ecart-type et commenter la stabilite de l’estimation

# Exercice 4 (variation) : stabilite du taux de validation
# TODO étudiant : estimez le taux de validation avec un intervalle de confiance.

# Étape 1 : N=20 tirages a un regime fixe (reutilisez generate_rules + stats_tirage)
# taux_par_tirage = []
# for draw in range(20):
#     rules = generate_rules(1000 + draw, bruit=True)
#     g, v = stats_tirage(rules)
#     taux_par_tirage.append(v / g)

# Étape 2 : moyenne et ecart-type du taux de validation
# moyenne = sum(taux_par_tirage) / len(taux_par_tirage)
# variance = sum((t - moyenne) ** 2 for t in taux_par_tirage) / len(taux_par_tirage)
# ecart_type = variance ** 0.5

# Étape 3 : intervalle moyenne +/- ecart-type
print("Exercice a completer : stabilite du taux de validation sur N=20 tirages")
print("Etape 1 : calculez le taux de validation de chacun des 20 tirages")
print("Etape 2 : derivez moyenne et ecart-type")
print("Etape 3 : affichez l'intervalle moyenne +/- ecart-type")

# Indice : plus l'ecart-type est petit devant la moyenne, plus l'estimation
# est stable -- et moins il faut de tirages pour conclure.
# result = None  # TODO étudiant : (moyenne, ecart_type)
Exercice a completer : stabilite du taux de validation sur N=20 tirages
Etape 1 : calculez le taux de validation de chacun des 20 tirages
Etape 2 : derivez moyenne et ecart-type
Etape 3 : affichez l'intervalle moyenne +/- ecart-type

9. Conclusion

Resume des concepts

Concept Description Analogie classique
LLM comme générateur Produit des hypotheses plausibles depuis des exemples Heuristique de recherche
Oracle symbolique Verifie formellement les hypotheses candidates Test de consistance (CBH)
Chain-of-Thought Raisonnement étape par étape du LLM Arbre de preuve EBL
Extraction de règles Parser les traces CoT en clauses logiques Variabilisation EBL
Pipeline iteratif Generer -> Verifier -> Raffiner CBH avec backtracking
Detection d’hallucinations Filtrer les règles impossibles ou triviales Verification de type

Forces et limites de l’approche

Aspect Avantage Limite
Creativite Le LLM genere des hypotheses variees Peut halluciner
Vitesse Plus rapide que la recherche exhaustive Depend du temps d’inference LLM
Fiabilite L’oracle garantit la correction Ne detecte pas les omissions
Generalisation Contexte naturel du LLM Limite par le prompt engineering
Scalabilite Pas besoin de domaine formel complet Cout des appels API

Liens avec les notebooks précédents

Notebook Connexion avec SL-9
SL-1 (CBH/VS) Le pipeline iteratif est analogue au CBH, avec le LLM comme générateur
SL-2 (EBL) Le CoT est une forme d’arbre de preuve EBL en langage naturel
SL-4 (ILP) L’oracle symbolique reprend les tests de couverture de l’ILP

Pour aller plus loin

  • Changer de modèle dans le .env (par ex. un plus petit modèle via OpenRouter) et observer l’effet sur le taux de validation par l’oracle : le notebook tourne déjà sur un vrai LLM, comparer les modèles est immediat.
  • Comparer avec les approches ILP pures (Progol, Aleph) sur des benchmarks (cf. SL-5).
  • Self-consistency : generer N reponses et voter pour la plus stable (cf. la mesure de stabilite de la section 7).
  • Connecter le même schema generer -> verifier au harnais de preuve Lean (agent_tests/prover) et aux harnais argumentatifs (TweetyProject, serie SymbolicAI).

Defi presentation

Modalite du cours : chaque groupe choisit un exercice de la serie, le prepare, et le presente en seance. Resoudre l’exercice est le minimum ; ce qui distingue une presentation qui maitrise le sujet, c’est la question-twist associee ci-dessous. Elle fait partie integrante de la presentation attendue.

Exercice Question-twist a traiter en plus
Ex. 1 (prompt personnalise) Changez de modèle dans le .env (un modèle plus petit via OpenRouter) et comparez le taux de validation oracle avec Gemini 3.5 Flash sur le même prompt. Que conclure sur la dépendance du pipeline au générateur — et sur ce que l’oracle garantit vraiment ?
Ex. 2 (prompt direct vs CoT) Le CoT ameliore-t-il le taux de validation oracle ou seulement la plausibilite du texte ? Les deux metriques peuvent diverger : montrez (ou construisez) un cas.
Ex. 3 (detection d’hallucinations) Votre detecteur s’appuie sur un oracle a données completes (monde clos). Que devient-il si les données sont incompletes (monde ouvert, cf SL-8) ? Proposez et justifiez une adaptation.
Ex. 4 (taux d’hallucination) Le taux d’hallucination est-il un defaut du générateur ou un paramètre d’exploration ? Cherchez le reglage qui maximise le nombre de règles VALIDEES par appel (et non le taux de validation) : pourquoi ces deux objectifs divergent-ils, et lequel l’architecture générateur+oracle permet-elle d’assumer sans risque ?

Ressources

  • Russell & Norvig, AI: A Modern Approach, 4e ed., Chapitre 19
  • Wei et al., Chain-of-Thought Prompting Elicits Reasoning in Large Language Models (NeurIPS 2022)
  • Muggleton, Inductive Logic Programming (1991)
  • Pan et al., Unifying Large Language Models and Knowledge Graphs: A Roadmap (2024)
  • TweetyProject - Bibliotheque Java pour la logique computationnelle

Retour : Index SymbolicLearning | << Knowledge Graphs ILP | SL-10 >>

Retour au sommet