SL-11 — Capstone : un pipeline neuro-symbolique de bout en bout

Navigation : Index | << SL-10

Objectifs d’apprentissage

Ce notebook integre les briques de toute la serie en un seul pipeline qui part de texte brut et finit par decouvrir des faits que le texte ne dit pas :

  1. Extraire des faits structures d’un corpus avec un vrai LLM (SL-9)
  2. Valider ces faits par un oracle symbolique : schema type, contraintes fonctionnelles (SL-1, SL-3)
  3. Peupler un graphe de connaissances (SL-8)
  4. Miner des règles de Horn par support/confiance, facon AMIE (SL-4, SL-8, SL-5)
  5. Inferer des faits nouveaux par chainage avant, avec provenance (SL-2)
  6. Confronter la reponse du LLM seul a celle du KG raisonne (SL-7, SL-9)

Prerequis

  • La serie SL-1 a SL-10 (chaque etage renvoie au notebook qui l’a introduit)
  • Acces LLM optionnel : .env a la racine de la serie (cf SL-9) ; un simulateur déterministe prend le relais hors-ligne

Duree estimee : 90 minutes


1. Le pipeline

   corpus (texte brut)
        |
        v
  [1] EXTRACTION LLM  ............ neuronal, faillible        (SL-9)
        |  triplets candidats
        v
  [2] ORACLE SYMBOLIQUE .......... types + contraintes        (SL-1, SL-3)
        |  triplets valides
        v
  [3] GRAPHE DE CONNAISSANCES .... stockage relationnel       (SL-8)
        |
        v
  [4] MINING DE RÈGLES ........... support / confiance        (SL-4, SL-8, SL-5)
        |  règles de Horn scorees
        v
  [5] CHAINAGE AVANT ............. faits NOUVEAUX + provenance (SL-2)
        |
        v
  [6] CONFRONTATION .............. LLM seul vs KG raisonne    (SL-7, SL-9)

La these de la serie, version operationnelle : le neuronal propose, le symbolique dispose. Le LLM lit le texte mieux qu’aucun parseur ; le symbolique verifie, structure et raisonne mieux qu’aucun LLM. Chaque etage va exhiber a la fois sa force et son angle mort — y compris le symbolique.


2. Le corpus et le schema

Une saga familiale et industrielle inventee. Notez les trois derniers enonces : deux sont des pieges deliberes (un registre contradictoire, une note mal typee), le troisieme est une information que seul le raisonnement permettra d’exploiter.

CORPUS = """
Henri Verne a fonde l'Atelier Verne en 1952, a Lyon.
Henri a deux enfants : Claire et Marc.
Claire dirige aujourd'hui l'Atelier Verne, ou elle travaille depuis trente ans.
Marc travaille lui aussi pour l'Atelier Verne.
Sophie est la fille de Claire, et elle travaille pour l'Atelier Verne.
Lucas est le fils de Marc.
Henri est le grand-pere de Lucas.
Nadia, la mere de Lucas, travaille pour la Fonderie Brun.
Paul dirige la Fonderie Brun, ou il a fait toute sa carriere.
Paul est le pere de Nadia, et le grand-pere de Lucas.
Sophie est nee a Lyon.
Un vieux registre pretend pourtant que Sophie serait nee a Bordeaux.
Une note marginale affirme que la Fonderie Brun a ete fondee par l'Atelier Verne.
""".strip()

# Schema : vocabulaire ferme de relations, avec signatures typees
PERSONS = {"henri", "claire", "marc", "sophie", "lucas", "nadia", "paul"}
ORGS = {"atelier_verne", "fonderie_brun"}
PLACES = {"lyon", "bordeaux"}
TYPE_OF = {**{e: "personne" for e in PERSONS},
           **{e: "organisation" for e in ORGS},
           **{e: "lieu" for e in PLACES}}

SIGNATURES = {
    "parent":         ("personne", "personne"),
    "grandparent":    ("personne", "personne"),
    "dirige":         ("personne", "organisation"),
    "travaille_pour": ("personne", "organisation"),
    "fondateur_de":   ("personne", "organisation"),
    "ne_a":           ("personne", "lieu"),
}
# Contraintes fonctionnelles : (relation, position fixee) -> l'autre est unique
FUNCTIONAL = {("ne_a", "sujet"),    # une personne a UN lieu de naissance
              ("dirige", "objet")}  # une organisation a UN dirigeant

print(f"Corpus : {len(CORPUS.splitlines())} enonces")
print(f"Schema : {len(SIGNATURES)} relations typees, "
      f"{len(PERSONS)} personnes, {len(ORGS)} organisations, {len(PLACES)} lieux")
print(f"Contraintes fonctionnelles : {sorted(FUNCTIONAL)}")
Corpus : 13 enonces
Schema : 6 relations typees, 7 personnes, 2 organisations, 2 lieux
Contraintes fonctionnelles : [('dirige', 'objet'), ('ne_a', 'sujet')]

Le schema est notre biais de langage (SL-4, SL-5) : un vocabulaire ferme de relations, des types, des contraintes. C’est lui qui rend l’oracle possible — et c’est lui qui decidera aussi de ce que le pipeline est incapable de voir.


3. Etage 1 : extraction par LLM

Même dispositif qu’en SL-9 : un .env a la racine de la serie configure n’importe quel endpoint OpenAI-compatible (ici OpenRouter / Gemini 3.5 Flash) ; sans .env, un simulateur déterministe — l’extraction de reference — prend le relais pour que le notebook reste executable hors-ligne.

# Configuration de l'acces LLM via .env (endpoint OpenAI-compatible)
import os
import re
from dotenv import load_dotenv

load_dotenv()  # charge le .env du repertoire du notebook s'il existe

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)

if LLM_AVAILABLE:
    from openai import OpenAI
    llm_client = OpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
    print("LLM reel configure :")
    print(f"  Endpoint : {LLM_BASE_URL}")
    print(f"  Modele   : {LLM_MODEL}")
    print("  (la cle API n'est jamais affichee)")
else:
    llm_client = None
    print("Pas de .env / OPENAI_API_KEY : extraction par le simulateur de reference.")
LLM reel configure :
  Endpoint : https://api.openai.com/v1
  Modele   : gpt-5.6-luna
  (la cle API n'est jamais affichee)
# Extraction de triplets : prompt structure + parsing + filet de securite

# L'extraction de reference (= la verite terrain, et le simulateur hors-ligne).
# Les DEUX derniers triplets sont les pieges du corpus, extraits fidelement :
# l'extraction rapporte ce que le texte AFFIRME, l'oracle jugera.
GOLD_TRIPLES = [
    ("fondateur_de", "henri", "atelier_verne"),
    ("parent", "henri", "claire"), ("parent", "henri", "marc"),
    ("dirige", "claire", "atelier_verne"), ("travaille_pour", "claire", "atelier_verne"),
    ("travaille_pour", "marc", "atelier_verne"),
    ("parent", "claire", "sophie"), ("travaille_pour", "sophie", "atelier_verne"),
    ("parent", "marc", "lucas"),
    ("grandparent", "henri", "lucas"),
    ("parent", "nadia", "lucas"), ("travaille_pour", "nadia", "fonderie_brun"),
    ("dirige", "paul", "fonderie_brun"), ("travaille_pour", "paul", "fonderie_brun"),
    ("parent", "paul", "nadia"), ("grandparent", "paul", "lucas"),
    ("ne_a", "sophie", "lyon"),
    ("ne_a", "sophie", "bordeaux"),                  # piege 1 : registre contradictoire
    ("fondateur_de", "atelier_verne", "fonderie_brun"),  # piege 2 : fondateur non humain
]

ENTITY_IDS = sorted(PERSONS | ORGS | PLACES)
EXTRACTION_PROMPT = f"""Extrais TOUS les faits affirmes par le texte ci-dessous sous forme
de triplets, un par ligne, au format strict : relation(sujet, objet)

Relations autorisees (aucune autre) : {", ".join(sorted(SIGNATURES))}
- parent(X, Y) signifie : X est le pere ou la mere de Y
- grandparent(X, Y) signifie : X est le grand-pere ou la grand-mere de Y
Identifiants d'entites a utiliser EXACTEMENT : {", ".join(ENTITY_IDS)}

Extrais aussi les faits presentes comme rumeurs ou notes douteuses : le filtrage
viendra apres. Reponds UNIQUEMENT avec les triplets, sans commentaire.

Texte :
{CORPUS}"""

def parse_triples(text):
    triples = []
    for m in re.finditer(r"(\w+)\(\s*([\w_]+)\s*,\s*([\w_]+)\s*\)", text):
        r, s, o = m.group(1).lower(), m.group(2).lower(), m.group(3).lower()
        if r in SIGNATURES and (r, s, o) not in triples:
            triples.append((r, s, o))
    return triples

if LLM_AVAILABLE:
    response = llm_client.chat.completions.create(
        model=LLM_MODEL,
        messages=[{"role": "user", "content": EXTRACTION_PROMPT}],
        max_completion_tokens=4000,  # gpt-5.6-luna (raisonneur) : max_completion_tokens requis, temperature non supportee
    )
    llm_triples = parse_triples(response.choices[0].message.content or "")
    source = LLM_MODEL
else:
    llm_triples = list(GOLD_TRIPLES)
    source = "simulateur (extraction de reference)"

# Qualite de l'extraction vs la reference
gold, got = set(GOLD_TRIPLES), set(llm_triples)
recall = len(gold & got) / len(gold)
precision = len(gold & got) / len(got) if got else 0.0
print(f"Extraction ({source}) : {len(llm_triples)} triplets")
print(f"  Rappel    : {len(gold & got)}/{len(gold)} = {recall:.2f}")
print(f"  Precision : {len(gold & got)}/{len(got)} = {precision:.2f}")
extras = sorted(got - gold)
if extras:
    print("  Triplets hors reference : " + ", ".join(f"{r}({s}, {o})" for r, s, o in extras))

# Filet de securite pour la suite du pipeline : on complete avec les faits
# manques (en pratique : extraction multi-passes + vote, cf le defi de l'Ex. 1)
missed = sorted(gold - got)
if missed:
    print(f"  Faits manques reinjectes pour la suite : "
          + ", ".join(f"{r}({s}, {o})" for r, s, o in missed))
triples = llm_triples + [t for t in GOLD_TRIPLES if t not in got]
print(f"\nEntree de l'oracle : {len(triples)} triplets")
Extraction (gpt-5.6-luna) : 19 triplets
  Rappel    : 19/19 = 1.00
  Precision : 19/19 = 1.00

Entree de l'oracle : 19 triplets

Interpretation

L’etage neuronal fait ce qu’aucune regex ne sait faire : lire « Nadia, la mere de Lucas » et en tirer parent(nadia, lucas). Mais il reste faillible et non verifie : le rappel et la precision affichés ci-dessus sont mesurables uniquement parce que nous possedons une extraction de reference — luxe inexistant en production. (Sur ce corpus jouet — 13 enonces, vocabulaire ferme, identifiants imposes — Gemini 3.5 Flash realise une extraction parfaite ; rien ne garantit qu’elle le reste sur du texte ouvert.) D’ou l’etage suivant : tout triplet, d’ou qu’il vienne, passe devant l’oracle. Notez que l’extraction rapporte fidelement les deux pieges du corpus : c’est le rôle de l’extraction de rester fidele au texte, et celui de l’oracle de juger.


4. Etage 2 : l’oracle symbolique

Trois verifications, dans l’esprit de la consistance du Version Space (SL-1) et des determinations (SL-3) : entites connues, types conformes a la signature, contraintes fonctionnelles (une personne n’a qu’un lieu de naissance ; une organisation n’a qu’un dirigeant). Politique de conflit : premier-arrive, premier-cru — politique discutable, et c’est un exercice.

def oracle(triples):
    accepted, rejected = [], []
    for (r, s, o) in triples:
        sig = SIGNATURES.get(r)
        if sig is None:
            rejected.append(((r, s, o), "relation inconnue")); continue
        if s not in TYPE_OF or o not in TYPE_OF:
            rejected.append(((r, s, o), "entite inconnue")); continue
        if (TYPE_OF[s], TYPE_OF[o]) != sig:
            rejected.append(((r, s, o),
                f"types ({TYPE_OF[s]}, {TYPE_OF[o]}) != signature {sig}")); continue
        conflict = None
        if (r, "sujet") in FUNCTIONAL:
            conflict = next((t for t in accepted if t[0] == r and t[1] == s and t[2] != o), None)
        if (r, "objet") in FUNCTIONAL and conflict is None:
            conflict = next((t for t in accepted if t[0] == r and t[2] == o and t[1] != s), None)
        if conflict:
            rejected.append(((r, s, o),
                f"contrainte fonctionnelle : {conflict[0]}({conflict[1]}, {conflict[2]}) deja admis"))
            continue
        accepted.append((r, s, o))
    return accepted, rejected


kg_facts, rejected = oracle(triples)
print(f"Oracle : {len(kg_facts)} acceptes, {len(rejected)} rejetes\n")
print("Rejets :")
for (r, s, o), reason in rejected:
    print(f"  {r}({s}, {o})")
    print(f"      -> {reason}")
Oracle : 17 acceptes, 2 rejetes

Rejets :
  ne_a(sophie, bordeaux)
      -> contrainte fonctionnelle : ne_a(sophie, lyon) deja admis
  fondateur_de(atelier_verne, fonderie_brun)
      -> types (organisation, organisation) != signature ('personne', 'organisation')

Interpretation

Les deux pieges sont morts ici, chacun tue par une arme différente :

  • fondateur_de(atelier_verne, fonderie_brun) — la note marginale — viole la signature : un fondateur est une personne dans notre schema. Verification purement structurelle, zero connaissance du monde.
  • ne_a(sophie, bordeaux) — le vieux registre — viole la contrainte fonctionnelle : Lyon est arrive avant. Remarquez l’arbitraire : l’oracle n’a aucun moyen de savoir lequel des deux registres ment, il applique une politique d’anciennete. Truth discovery, gestion des sources, votes ponderes : tout un champ de recherche vit dans cette ligne (defi de l’Ex. 2).

Un LLM interroge sans cet etage aurait pu retenir Bordeaux, Lyon, ou les deux selon le prompt. L’oracle, lui, est previsible — y compris dans ses erreurs.


5. Etage 3 : le graphe de connaissances

Les faits valides forment un KG (SL-8) : sommets = entites, aretes etiquetees = relations. C’est le substrat sur lequel les deux etages suivants vont travailler.

from collections import defaultdict

by_rel = defaultdict(list)
for r, s, o in kg_facts:
    by_rel[r].append((s, o))

print(f"KG : {len(kg_facts)} faits, {len(by_rel)} relations, "
      f"{len({e for _, s, o in kg_facts for e in (s, o)})} entites\n")
for r in sorted(by_rel):
    pairs = ", ".join(f"({s}, {o})" for s, o in sorted(by_rel[r]))
    print(f"  {r:<15} {pairs}")
KG : 17 faits, 6 relations, 10 entites

  dirige          (claire, atelier_verne), (paul, fonderie_brun)
  fondateur_de    (henri, atelier_verne)
  grandparent     (henri, lucas), (paul, lucas)
  ne_a            (sophie, lyon)
  parent          (claire, sophie), (henri, claire), (henri, marc), (marc, lucas), (nadia, lucas), (paul, nadia)
  travaille_pour  (claire, atelier_verne), (marc, atelier_verne), (nadia, fonderie_brun), (paul, fonderie_brun), (sophie, atelier_verne)

6. Etage 4 : miner des règles (AMIE-lite)

Le mineur enumere deux gabarits de règles de Horn — l’implication directe \(h(X,Y) \leftarrow b(X,Y)\) et la chaîne \(h(X,Y) \leftarrow b_1(X,Z) \wedge b_2(Z,Y)\) — et les score comme AMIE (SL-8) :

\[\text{support} = |\{(x,y) : \text{corps}(x,y) \wedge h(x,y)\}| \qquad \text{confiance} = \frac{\text{support}}{|\{(x,y) : \text{corps}(x,y)\}|}\]

Seuils : support \(\geq 2\), confiance \(\geq 0.6\). La grammaire est minuscule comparee a SL-5 (pas de clause bottom ici) mais l’esprit est le même : un espace d’hypotheses borne, un score, un seuil.

def mine_rules(facts, min_support=2, min_conf=0.6):
    rels = defaultdict(set)
    for r, s, o in facts:
        rels[r].add((s, o))
    names = sorted(rels)
    candidates = []
    for h in names:                       # gabarit 1 : h(X,Y) :- b(X,Y)
        for b in names:
            if h == b:
                continue
            inst = rels[b]
            support = len(inst & rels[h])
            if support >= min_support:
                candidates.append({"kind": "impl", "head": h, "b1": b, "b2": None,
                                   "support": support, "conf": support / len(inst),
                                   "text": f"{h}(X,Y) :- {b}(X,Y)"})
    for h in names:                       # gabarit 2 : h(X,Y) :- b1(X,Z), b2(Z,Y)
        for b1 in names:
            for b2 in names:
                inst = {(x, y) for (x, z) in rels[b1]
                        for (z2, y) in rels[b2] if z == z2 and x != y}
                if not inst:
                    continue
                support = len(inst & rels[h])
                if support >= min_support:
                    candidates.append({"kind": "chain", "head": h, "b1": b1, "b2": b2,
                                       "support": support, "conf": support / len(inst),
                                       "text": f"{h}(X,Y) :- {b1}(X,Z), {b2}(Z,Y)"})
    return candidates, min_conf


candidates, MIN_CONF = mine_rules(kg_facts)
print(f"{'regle':<55} | support | conf | verdict")
print("-" * 85)
for c in sorted(candidates, key=lambda c: -c["conf"]):
    verdict = "RETENUE" if c["conf"] >= MIN_CONF else "rejetee"
    print(f"{c['text']:<55} |    {c['support']}    | {c['conf']:.2f} | {verdict}")
rules = [c for c in candidates if c["conf"] >= MIN_CONF]
regle                                                   | support | conf | verdict
-------------------------------------------------------------------------------------
travaille_pour(X,Y) :- dirige(X,Y)                      |    2    | 1.00 | RETENUE
dirige(X,Y) :- parent(X,Z), travaille_pour(Z,Y)         |    2    | 0.67 | RETENUE
grandparent(X,Y) :- parent(X,Z), parent(Z,Y)            |    2    | 0.67 | RETENUE
travaille_pour(X,Y) :- parent(X,Z), travaille_pour(Z,Y) |    2    | 0.67 | RETENUE
dirige(X,Y) :- travaille_pour(X,Y)                      |    2    | 0.40 | rejetee

Interpretation : quatre retenues, dont deux suspectes

  • travaille_pour(X,Y) :- dirige(X,Y) (confiance 1.00) : du quasi-schema — un dirigeant travaille pour son organisation. Vrai, mais peu informatif.
  • grandparent(X,Y) :- parent(X,Z), parent(Z,Y) (confiance 0.67) : la definition, retrouvee par comptage pur. La confiance n’est pas 1.0 car le KG connait un couple parent-de-parent (Henri-Claire-Sophie) sans fait grandparent correspondant — c’est exactement le trou que l’inference va combler.
  • travaille_pour(X,Y) :- parent(X,Z), travaille_pour(Z,Y) (confiance 0.67) : première règle suspecte. Dans notre saga d’entreprises familiales, les parents travaillent souvent la ou travaillent leurs enfants — correlation de ce micro-monde, pas loi du monde reel.
  • dirige(X,Y) :- parent(X,Z), travaille_pour(Z,Y) (confiance 0.67) : la franchement fausse — « on dirige l’entreprise ou travaillent ses enfants » ! Elle doit sa confiance aux mêmes coincidences (Claire dirige la ou travaille sa fille Sophie, Paul la ou travaille sa fille Nadia). Le mineur ne fait pas la différence : support et confiance mesurent la regularite des données, pas la causalite.
  • dirige(X,Y) :- travaille_pour(X,Y) (confiance 0.40) tombe sous le seuil… alors qu’elle est moins absurde que la précédente. Le seuil filtre par frequence, pas par sens — l’exercice 3 quantifie cette impuissance.

7. Etage 5 : chainage avant — la decouverte

On applique les règles retenues jusqu’au point fixe (chainage avant, SL-2), en gardant pour chaque fait nouveau sa provenance : la règle et les faits temoins qui l’ont produit.

def forward_chain(facts, rules):
    known = set(facts)
    derived = []
    changed = True
    while changed:
        changed = False
        rels = defaultdict(set)
        for r, s, o in known:
            rels[r].add((s, o))
        for rule in rules:
            if rule["kind"] == "impl":
                produced = [((rule["head"], s, o), [(rule["b1"], s, o)])
                            for (s, o) in rels[rule["b1"]]]
            else:
                produced = [((rule["head"], x, y),
                             [(rule["b1"], x, z), (rule["b2"], z, y)])
                            for (x, z) in rels[rule["b1"]]
                            for (z2, y) in rels[rule["b2"]] if z == z2 and x != y]
            for fact, witnesses in produced:
                if fact not in known:
                    known.add(fact)
                    derived.append((fact, rule, witnesses))
                    changed = True
    return known, derived


kg_inferred, derived = forward_chain(kg_facts, rules)
print(f"Chainage avant : {len(derived)} fait(s) nouveau(x) "
      f"(KG : {len(kg_facts)} -> {len(kg_inferred)})\n")
for (r, s, o), rule, witnesses in derived:
    print(f"  NOUVEAU : {r}({s}, {o})")
    print(f"    regle   : {rule['text']}  (conf {rule['conf']:.2f})")
    print(f"    temoins : " + ", ".join(f"{w[0]}({w[1]}, {w[2]})" for w in witnesses))
Chainage avant : 3 fait(s) nouveau(x) (KG : 17 -> 20)

  NOUVEAU : dirige(henri, atelier_verne)
    regle   : dirige(X,Y) :- parent(X,Z), travaille_pour(Z,Y)  (conf 0.67)
    temoins : parent(henri, marc), travaille_pour(marc, atelier_verne)
  NOUVEAU : grandparent(henri, sophie)
    regle   : grandparent(X,Y) :- parent(X,Z), parent(Z,Y)  (conf 0.67)
    temoins : parent(henri, claire), parent(claire, sophie)
  NOUVEAU : travaille_pour(henri, atelier_verne)
    regle   : travaille_pour(X,Y) :- parent(X,Z), travaille_pour(Z,Y)  (conf 0.67)
    temoins : parent(henri, marc), travaille_pour(marc, atelier_verne)

Interpretation : le payoff… et les poisons

Trois faits nouveaux, trois statuts :

  1. grandparent(henri, sophie) — la decouverte. Aucune phrase du corpus ne le dit. Le pipeline l’a obtenu en combinant deux faits extraits par le LLM et une règle minee par comptage : c’est la promesse neuro-symbolique tenue, et la provenance affichée en est la preuve auditable.

  2. travaille_pour(henri, atelier_verne) — l’artefact plausible. Produit par la première règle suspecte : le fondateur a sans doute travaille dans son atelier… avant la retraite ? Rien dans le texte ne le soutient.

  3. dirige(henri, atelier_verne) — l’artefact illegal. Non seulement probablement faux (c’est Claire qui dirige), mais il viole la contrainte fonctionnelle que l’oracle a fait respecter a l’etage 2 : une organisation, un dirigeant. Le KG contient desormais deux dirigeants pour l’Atelier Verne. La lecon est architecturale : le chainage avant injecte ses conclusions sans les re-soumettre a l’oracle. Un pipeline coherent re-validerait chaque fait derive — et un rejet devrait alors faire baisser la confiance de la règle fautive (boucle de retro-action que le defi de l’Ex. 4 explore).

Même mécanisme, même confiance 0.67, trois valeurs de verite différentes. La seule différence visible de l’exterieur : la provenance, qui permet au moins de remonter la chaîne et d’incriminer la règle.


8. Etage 6 : la boucle bouclee — LLM seul contre KG raisonne

Question multi-sauts dont la reponse complete exige l’inference : « Qui sont les petits-enfants de Henri ? » Le corpus ne nomme que Lucas ; Sophie n’est atteignable que par raisonnement. On compare la reponse du LLM lisant le corpus a celle du KG enrichi.

QUESTION = "Qui sont les petits-enfants de Henri ? Reponds en une phrase."

# Reponse du KG : simple requete sur le graphe enrichi
kg_answer = sorted(o for (r, s, o) in kg_inferred if r == "grandparent" and s == "henri")
print(f"Reponse du KG raisonne : {kg_answer}")
for (r, s, o), rule, witnesses in derived:
    if r == "grandparent" and s == "henri":
        print(f"  dont {o} INFERE via {rule['text']}")
        print(f"  temoins : " + ", ".join(f"{w[0]}({w[1]}, {w[2]})" for w in witnesses))

# Reponse du LLM seul, corpus en contexte
if LLM_AVAILABLE:
    qa = llm_client.chat.completions.create(
        model=LLM_MODEL,
        messages=[{"role": "user",
                   "content": f"Texte :\n{CORPUS}\n\nQuestion : {QUESTION}"}],
        max_completion_tokens=4000,
    )
    print(f"\nReponse du LLM seul ({LLM_MODEL}) :")
    print(f"  {(qa.choices[0].message.content or '').strip()}")
else:
    print("\n(pas de .env : comparaison LLM indisponible hors-ligne)")
Reponse du KG raisonne : ['lucas', 'sophie']
  dont sophie INFERE via grandparent(X,Y) :- parent(X,Z), parent(Z,Y)
  temoins : parent(henri, claire), parent(claire, sophie)

Reponse du LLM seul (gpt-5.6-luna) :
  Les petits-enfants de Henri sont Sophie et Lucas.

Interpretation

Que le LLM reussisse ou non ce saut a deux pas (les modèles recents y arrivent souvent), la différence de nature entre les deux reponses demeure :

  • la reponse du KG vient avec une derivation : deux faits temoins, une règle, sa confiance — chaque maillon est inspectable, contestable, corrigeable ;
  • la reponse du LLM est une affirmation : peut-etre juste, mais sans autre garantie que la vraisemblance statistique, et silencieusement dependante du modèle, du prompt et de la temperature (SL-9).

C’est le partage des rôles defendu depuis SL-7 : au neuronal la lecture, au symbolique la responsabilite. Et l’angle mort reste partage lui aussi : le KG affirme travaille_pour(henri, atelier_verne) — et même dirige(henri, atelier_verne), en contradiction avec sa propre contrainte fonctionnelle — avec le même aplomb — la provenance est une piste d’audit, pas un certificat de verite.


9. Exercices

Tableau recapitulatif

Etage Mécanisme Fonction
Extraction LLM + vocabulaire ferme + parsing texte -> triplets candidats
Oracle types, signatures, contraintes fonctionnelles triplets -> faits valides
KG index par relation stockage + requêtes
Mining gabarits impl/chaîne, support + confiance faits -> règles scorees
Inference chainage avant + provenance règles -> faits nouveaux
Confrontation requête KG vs question LLM audit de la reponse
# Exemple guide 1 : Etendre le schema (relation symetrique marie_avec)
#
# On ajoute la relation marie_avec(personne, personne) au schema, l'entite
# jeanne, et le fait marie_avec(henri, jeanne). La nouveaute : cette relation
# est SYMETRIQUE. L'oracle doit completer automatiquement marie_avec(Y,X) des
# que marie_avec(X,Y) est admis -- sinon on perd la moitie du mariage.

# Etape 1 : on etend le vocabulaire ferme (le biais de langage, cf SL-4/SL-9).
SIGNATURES["marie_avec"] = ("personne", "personne")
PERSONS.add("jeanne")
TYPE_OF["jeanne"] = "personne"
print("Schema etendu : marie_avec ajoutee, jeanne declaree personne.")

# Etape 2 : le nouveau fait. Hors-ligne (pas de .env) l'extraction LLM n'est pas
# disponible : on injecte le triplet "or" exactement comme le ferait une
# extraction LLM multi-passes + vote (cf le filet de securite de la cellule
# d'extraction). En ligne, EXTRACTION_PROMPT extrairait marie_avec(henri, jeanne).
marie_triples = [("marie_avec", "henri", "jeanne")]

# Etape 3 : oracle ETENDU. On reutilise oracle() pour les verifications de type
# et de contrainte fonctionnelle, puis on ajoute la completion symetrique POUR
# LES RELATIONS DECLAREES SYMETRIQUES (et elles seules).
def oracle_symmetric(triples, symmetric_rels):
    accepted, rejected = oracle(triples)          # types + contraintes fonctionnelles
    complets = []
    for (r, s, o) in accepted:
        if r in symmetric_rels and (r, o, s) not in accepted and (r, o, s) not in complets:
            complets.append((r, o, s))           # marie_avec(henri,jeanne) -> (jeanne,henri)
    return accepted + complets, rejected

# Etape 4 : re-execution de l'oracle sur le KG + le nouveau fait.
kg2, rej2 = oracle_symmetric(kg_facts + marie_triples, symmetric_rels={"marie_avec"})
mariages = sorted((s, o) for (r, s, o) in kg2 if r == "marie_avec")
print(f"\nFaits marie_avec apres completion symetrique : {len(mariages)}")
for s, o in mariages:
    print(f"  marie_avec({s}, {o})")
print(f"\nKG : {len(kg_facts)} -> {len(kg2)} faits "
      f"(17 admis + 1 mariage + 1 completion).")
print()
print("Lecture : un seul triplet en entree, DEUX en sortie. La completion")
print("symetrique est une regle du SCHEMA (au meme titre qu'une contrainte")
print("fonctionnelle), pas une regle MINEE : elle est vraie par definition, on")
print("n'a pas besoin de donnees pour la confirmer -- contrairement aux regles")
print("de la cellule mine_rules dont la confiance se mesure par comptage.")
Schema etendu : marie_avec ajoutee, jeanne declaree personne.

Faits marie_avec apres completion symetrique : 2
  marie_avec(henri, jeanne)
  marie_avec(jeanne, henri)

KG : 17 -> 19 faits (17 admis + 1 mariage + 1 completion).

Lecture : un seul triplet en entree, DEUX en sortie. La completion
symetrique est une regle du SCHEMA (au meme titre qu'une contrainte
fonctionnelle), pas une regle MINEE : elle est vraie par definition, on
n'a pas besoin de donnees pour la confirmer -- contrairement aux regles
de la cellule mine_rules dont la confiance se mesure par comptage.

Exercice 1 (variation) : Symetrie vs anti-symetrie

La completion symetrique ne doit s’appliquer qu’aux relations declarees symetriques. Verifiez qu’une relation anti-symetrique comme parent n’est jamais completee en parent(Y, X), même si on la passe a oracle_symmetric.

Étapes : 1. Appeler oracle_symmetric(kg_facts, symmetric_rels={"marie_avec"}) et recuperer les faits parent. 2. Verifier qu’aucun parent(Y, X) n’a ete ajoute par completion (la liste des faits parent avant/après est identique). 3. Que faudrait-il faire pour qu’une nouvelle relation symetrique beneficie automatiquement de la completion, sans modifier le code de oracle_symmetric ?

Indice : c’est l’appartenance a symmetric_rels qui ouvre ou ferme la porte. Une relation symetrique oubliee dans cet ensemble = un demi-mariage silencieux (un seul sens persiste, l’autre n’est jamais derive).

# Exercice 1 (variation) : Symetrie vs anti-symetrie
# TODO etudiant : montrez que la completion symetrique respecte la porte
# symmetric_rels et n'ajoute JAMAIS parent(Y, X) pour un parent(X, Y) admis.

# Etape 1 : oracle_symmetric avec SEULEMENT marie_avec comme symetrique
# kg3, _ = oracle_symmetric(kg_facts, symmetric_rels={"marie_avec"})

# Etape 2 : faits parent avant / apres -> doivent etre EGAUX (rien complete)
# parents_avant = {(s, o) for (r, s, o) in kg_facts if r == "parent"}
# parents_apres = {(s, o) for (r, s, o) in kg3 if r == "parent"}

# Etape 3 : conclure sur le role de la porte symmetric_rels
print("Exercice a completer : symetrie vs anti-symetrie")
print("Montrez qu'aucun parent(Y, X) n'est ajoute : la porte symmetric_rels reste fermee.")

# Indice : la completion ne s'applique qu'aux relations de symmetric_rels.
# Ajouter une relation symetrique = l'inscrire dans l'ensemble, pas coder.
# result = None  # TODO etudiant : parents_avant == parents_apres ?
Exercice a completer : symetrie vs anti-symetrie
Montrez qu'aucun parent(Y, X) n'est ajoute : la porte symmetric_rels reste fermee.

L’exercice suivant attaque la politique de conflit de l’oracle — le premier-arrive-premier-cru applique au registre de Bordeaux.

# Exemple guide 2 : Une meilleure politique de conflit (oracle a sources)
#
# L'oracle actuel resout les conflits fonctionnels par "premier arrive, premier
# cru" : comme ne_a(sophie, lyon) arrive avant ne_a(sophie, bordeaux), lyon gagne
# -- par chance, car le texte principal est enonce avant le vieux registre. On
# rend cette decision EXPLICITE et robuste a l'ordre : chaque triplet porte une
# etiquette de source et un score de fiabilite ; en cas de conflit fonctionnel,
# on garde le PLUS FIABLE, quel que soit l'ordre d'arrivee.

# Etape 1 : fiabilite par source
FIABILITE = {"texte_principal": 0.9, "vieux_registre": 0.4}


def oracle_avec_sources(triples_etiquetes):
    """triples_etiquetes : liste de (relation, sujet, objet, source).

    Sur un conflit fonctionnel, garde le triplet de la source la plus fiable,
    QUEL QUE SOIT l'ordre d'arrivee. Renvoie (acceptes, rejetes) ou chaque
    element porte sa source.
    """
    accepts, rejects = [], []
    for (r, s, o, source) in triples_etiquetes:
        # --- verifications de type (reutilisees de oracle) ---
        sig = SIGNATURES.get(r)
        if sig is None:
            rejects.append(((r, s, o, source), "relation inconnue")); continue
        if s not in TYPE_OF or o not in TYPE_OF:
            rejects.append(((r, s, o, source), "entite inconnue")); continue
        if (TYPE_OF[s], TYPE_OF[o]) != sig:
            rejects.append(((r, s, o, source),
                f"types ({TYPE_OF[s]}, {TYPE_OF[o]}) != signature {sig}")); continue
        # --- conflit fonctionnel parmi les deja admis ---
        conflit = None
        for (rr, ss, oo, src) in accepts:
            if rr != r:
                continue
            if (r, "sujet") in FUNCTIONAL and ss == s and oo != o:
                conflit = (rr, ss, oo, src); break        # ne_a(sophie, *)
            if (r, "objet") in FUNCTIONAL and oo == o and ss != s:
                conflit = (rr, ss, oo, src); break        # dirige(*, org)
        if conflit is not None:
            f_new, f_old = FIABILITE[source], FIABILITE[conflit[3]]
            if f_new > f_old:
                # le nouveau est PLUS fiable : il ejecte l'ancien, meme arrive apres
                accepts = [t for t in accepts if t != conflit]
                rejects.append(((conflit[0], conflit[1], conflit[2], conflit[3]),
                    f"ejecte : source {conflit[3]} ({f_old}) < nouvelle {source} ({f_new})"))
                accepts.append((r, s, o, source))
            else:
                rejects.append(((r, s, o, source),
                    f"rejete : source {source} ({f_new}) <= admise {conflit[3]} ({f_old})"))
            continue
        accepts.append((r, s, o, source))
    return accepts, rejects


# Etape 2 : on etiquette le flux d'extraction. Le piege bordeaux vient du
# vieux_registre ; tout le reste, du texte_principal.
VIEUX_REGISTRE = {("ne_a", "sophie", "bordeaux")}
flux = [(r, s, o, "vieux_registre" if (r, s, o) in VIEUX_REGISTRE else "texte_principal")
        for (r, s, o) in triples]

# Etape 3 : on isole le conflit ne_a(sophie, *) et on teste les DEUX ordres.
ne_lyon = ("ne_a", "sophie", "lyon", "texte_principal")
ne_bord = ("ne_a", "sophie", "bordeaux", "vieux_registre")
for nom, ordre in [("texte d'abord", [ne_lyon, ne_bord]),
                   ("registre d'abord", [ne_bord, ne_lyon])]:
    acc, rej = oracle_avec_sources(ordre)
    gagnant = [o for (r, s, o, _src) in acc if r == "ne_a" and s == "sophie"]
    print(f"Ordre [{nom}] : ne_a(sophie, ?) admis = {gagnant}")

print()
acc, rej = oracle_avec_sources(flux)
print(f"Oracle a sources (flux complet) : {len(acc)} acceptes, {len(rej)} rejetes")
for (r, s, o, source), raison in rej:
    print(f"  REJETE {r}({s}, {o}) [{source}] -> {raison}")

print()
print("Lecture : dans les DEUX ordres, lyon (texte_principal, 0.9) l'emporte sur")
print("bordeaux (vieux_registre, 0.4). La decision ne depend plus de la chance de")
print("l'enonce : elle est encodee dans la fiabilite des sources. Attention au cas")
print("d'egalite (deux sources de meme fiabilite) : le tie-break retombe sur")
print("premier-arrive (f_new > f_old est strict) -- l'exercice variation l'attaque.")
Ordre [texte d'abord] : ne_a(sophie, ?) admis = ['lyon']
Ordre [registre d'abord] : ne_a(sophie, ?) admis = ['lyon']

Oracle a sources (flux complet) : 17 acceptes, 2 rejetes
  REJETE ne_a(sophie, bordeaux) [vieux_registre] -> rejete : source vieux_registre (0.4) <= admise texte_principal (0.9)
  REJETE fondateur_de(atelier_verne, fonderie_brun) [texte_principal] -> types (organisation, organisation) != signature ('personne', 'organisation')

Lecture : dans les DEUX ordres, lyon (texte_principal, 0.9) l'emporte sur
bordeaux (vieux_registre, 0.4). La decision ne depend plus de la chance de
l'enonce : elle est encodee dans la fiabilite des sources. Attention au cas
d'egalite (deux sources de meme fiabilite) : le tie-break retombe sur
premier-arrive (f_new > f_old est strict) -- l'exercice variation l'attaque.

Exercice 2 (variation) : Vote sur egalite de fiabilite

Quand deux sources ont la même fiabilite et se contredisent, le tie-break actuel retombe sur “premier arrive”. Remplacez-le par un vote : comptez les soutiens de chaque triplet avant de trancher — celui qui recueille le cumul de fiabilite (ou le nombre de soutiens) le plus eleve gagne.

Étapes : 1. Etiqueter 3 sources : texte_principal (0.9), acte_officiel (0.9), vieux_registre (0.4). Deux sources a 0.9 soutiennent ne_a(sophie, lyon) ; une a 0.4 soutient ne_a(sophie, bordeaux). 2. Implementer oracle_vote(triples_etiquetes, fiabilite) : grouper par triplet, puis au conflit garder le triplet au cumul de fiabilite le plus eleve. 3. Verifier que lyon l’emporte même si bordeaux arrive en premier, ET qu’aucune source individuelle ne domine seule.

Indice : le vote transforme une decision binaire source-contre-source en une agregation ponderee — c’est l’idee du filet de securite multi-passes + vote mentionne dans la cellule d’extraction.

# Exercice 2 (variation) : Vote sur egalite de fiabilite
# TODO etudiant : quand deux sources de MEME fiabilite se contredisent, le
# tie-break actuel retombe sur premier-arrive. Remplacez-le par un VOTE.

# Etape 1 : 3 sources -- deux a 0.9 pour lyon, une a 0.4 pour bordeaux
# sources = {"texte_principal": 0.9, "acte_officiel": 0.9, "vieux_registre": 0.4}
# flux_vote = [
#     ("ne_a", "sophie", "lyon", "texte_principal"),
#     ("ne_a", "sophie", "lyon", "acte_officiel"),
#     ("ne_a", "sophie", "bordeaux", "vieux_registre"),
# ]

# Etape 2 : oracle_vote : au conflit, garder le triplet au cumul de fiabilite
# (ou nombre de soutiens) le plus eleve -- independant de l'ordre d'arrivee.
# def oracle_vote(triples_etiquetes, fiabilite):
#     ...

# Etape 3 : lyon doit gagner MEME si bordeaux arrive en premier
print("Exercice a completer : vote sur egalite de fiabilite")
print("Etape 1 : 3 sources (deux a 0.9 pour lyon, une a 0.4 pour bordeaux)")
print("Etape 2 : oracle_vote tranche par cumul de fiabilite / nombre de soutiens")
print("Etape 3 : lyon gagne quel que soit l'ordre d'arrivee")

# Indice : grouper par (r, s, o), sommer les fiabilites, garder le max.
# C'est l'agregation ponderee derriere le "multi-passes + vote" de l'extraction.
# result = None  # TODO etudiant : gagnant du vote
Exercice a completer : vote sur egalite de fiabilite
Etape 1 : 3 sources (deux a 0.9 pour lyon, une a 0.4 pour bordeaux)
Etape 2 : oracle_vote tranche par cumul de fiabilite / nombre de soutiens
Etape 3 : lyon gagne quel que soit l'ordre d'arrivee

L’exercice suivant quantifie ce que les seuils du mineur font vraiment : chaque choix de seuil est un compromis entre règles manquees et règles fausses.

# Exemple guide 3 : Le bon seuil n'existe pas
#
# La confiance filtre les regles minees par FREQUENCE, pas par SENS. On quantifie
# cette impuissance : on fait varier min_conf de 0.0 a 1.0, on classe CHAQUE
# candidate a la main (loi du domaine VRAIE vs coincidence du micro-monde
# FAUSSE), et on cherche s'il existe un seuil qui garde toutes les vraies sans
# aucune fausse.

# Etape 1 : verdict manuel de chaque candidate (cf l'interpretation de mine_rules)
VERDICT = {
    "travaille_pour(X,Y) :- dirige(X,Y)":                      "VRAIE",   # 1.00 quasi-schema
    "grandparent(X,Y) :- parent(X,Z), parent(Z,Y)":            "VRAIE",   # 0.67 la definition
    "dirige(X,Y) :- parent(X,Z), travaille_pour(Z,Y)":         "FAUSSE",  # 0.67 franchement fausse
    "travaille_pour(X,Y) :- parent(X,Z), travaille_pour(Z,Y)": "FAUSSE",  # 0.67 suspecte
    "dirige(X,Y) :- travaille_pour(X,Y)":                      "FAUSSE",  # 0.40 sur-generalisation
}

# Etape 2 : balayage des seuils -- on recombine les memes candidates, sans
# re-miner, en changeant seulement le seuil de retention.
print(f"{'seuil':>5} | {'vraies':>6} | {'fausses':>7} | separable ?")
print("-" * 45)
separable_trouve = False
for seuil_10 in range(0, 11):
    seuil = seuil_10 / 10
    retenues = [c for c in candidates if c["conf"] >= seuil]
    v = sum(1 for c in retenues if VERDICT[c["text"]] == "VRAIE")
    f = sum(1 for c in retenues if VERDICT[c["text"]] == "FAUSSE")
    sep = (v == 2 and f == 0)   # garde toutes les vraies, aucune fausse
    separable_trouve = separable_trouve or sep
    print(f"{seuil:>5.1f} | {v:>6} | {f:>7} | {'OUI' if sep else 'non'}")

print()
print(f"Un seuil separant toutes les VRAIES de toutes les FAUSSES existe-t-il ? "
      f"{'OUI' if separable_trouve else 'NON.'}")
print()
print("Lecture : grandparent (VRAIE) et travaille_pour :- parent o travaille_pour")
print("(FAUSSE) ont la MEME confiance 0.67. Tout seuil <= 0.67 les garde toutes")
print("les deux (fausses qui leurent) ; tout seuil > 0.67 les jette toutes les")
print("deux (vraie perdue). Le seuil ne peut pas couper ENTRE deux regles de meme")
print("confiance : le bon seuil n'existe pas. Pour separer ces deux regles il")
print("faudrait un SCORE DIFFERENT de la confiance -- un score qui recompense le")
print("fait que grandparent est causalement amont, ou penalise l'effet 'conjoint'")
print("(travail des enfants => travail des parents). Les donnees seules (comptage)")
print("ne suffisent pas : il faut soit un biais de gabarit (refuser une tete")
print("'travaille_pour' derivee d'une chaine par 'parent'), soit des contre-exemples")
print("(un parent dont l'enfant travaille ailleurs sans y travailler lui-meme).")
seuil | vraies | fausses | separable ?
---------------------------------------------
  0.0 |      2 |       3 | non
  0.1 |      2 |       3 | non
  0.2 |      2 |       3 | non
  0.3 |      2 |       3 | non
  0.4 |      2 |       3 | non
  0.5 |      2 |       2 | non
  0.6 |      2 |       2 | non
  0.7 |      1 |       0 | non
  0.8 |      1 |       0 | non
  0.9 |      1 |       0 | non
  1.0 |      1 |       0 | non

Un seuil separant toutes les VRAIES de toutes les FAUSSES existe-t-il ? NON.

Lecture : grandparent (VRAIE) et travaille_pour :- parent o travaille_pour
(FAUSSE) ont la MEME confiance 0.67. Tout seuil <= 0.67 les garde toutes
les deux (fausses qui leurent) ; tout seuil > 0.67 les jette toutes les
deux (vraie perdue). Le seuil ne peut pas couper ENTRE deux regles de meme
confiance : le bon seuil n'existe pas. Pour separer ces deux regles il
faudrait un SCORE DIFFERENT de la confiance -- un score qui recompense le
fait que grandparent est causalement amont, ou penalise l'effet 'conjoint'
(travail des enfants => travail des parents). Les donnees seules (comptage)
ne suffisent pas : il faut soit un biais de gabarit (refuser une tete
'travaille_pour' derivee d'une chaine par 'parent'), soit des contre-exemples
(un parent dont l'enfant travaille ailleurs sans y travailler lui-meme).

Exercice 3 (variation) : Un score qui separe

La confiance echoue a separer grandparent (VRAIE) de travaille_pour :- parent o travaille_pour (FAUSSE), toutes deux a 0.67. Inventez un score complementaire qui les separe.

Étapes : 1. Pour chaque candidate, calculer un score de diversite du support : nombre de SUJETS distincts dans ses instances de support (un vrai grandparent vient de plusieurs familles ; une coincidence de micro-monde tient souvent a une seule). 2. Re-classer les candidates par ce score et verifier que grandparent > travaille_pour :- parent. 3. Quelle paire de règles ce score echoue-t-il a separer a son tour ?

Indice : aucun score unique ne separe tout – c’est le moteur des approches multi-critères (confidence + support + diversite + leverage) en association rule mining.

# Exercice 3 (variation) : Un score qui separe
# TODO etudiant : la confiance ne separe pas grandparent (VRAIE) de
# travaille_pour:-parent (FAUSSE), toutes deux a 0.67. Inventez un score de
# DIVERSITE du support.

# Etape 1 : pour chaque candidate, compter les SUJETS distincts dans ses
# instances de support (support = inst & rels[head]).
# def diversite_support(candidate, kg_facts):
#     ...

# Etape 2 : re-classer par diversite et verifier grandparent > travaille_pour:-parent
# Etape 3 : quelle paire ce score echoue-t-il a separer a son tour ?

print("Exercice a completer : un score complementaire separateur")
print("Etape 1 : diversite du support = nombre de sujets distincts")
print("Etape 2 : grandparent doit battre travaille_pour:-parent")
print("Etape 3 : quelle paire reste inseparable ?")

# Indice : aucun score unique ne separe tout -> multi-criteres (conf + support +
# diversite + leverage). C'est le coeur de l'association rule mining.
# result = None  # TODO etudiant : classement par diversite
Exercice a completer : un score complementaire separateur
Etape 1 : diversite du support = nombre de sujets distincts
Etape 2 : grandparent doit battre travaille_pour:-parent
Etape 3 : quelle paire reste inseparable ?

Dernier exercice : l’attaque de bout en bout. Une phrase fausse mais parfaitement typee traverse-t-elle tout le pipeline ?

# Exemple guide 4 : Empoisonnement de bout en bout
#
# On injecte une phrase fausse mais parfaitement typee : "Lucas est le pere de
# Sophie." -> parent(lucas, sophie). Elle passe l'oracle (types personne/personne,
# parent n'est pas fonctionnel -> pas de conflit), puis l'etape d'INFERENCE
# applique les regles DEJA APPRISES au KG etendu. On trace la propagation et on
# demande quelle etape aurait PU l'arreter.

# Etape 1 : le poison. On le fait passer l'oracle pour montrer qu'il est admis.
POISON = ("parent", "lucas", "sophie")
acc_p, rej_p = oracle([POISON])
print(f"Le poison {POISON[0]}({POISON[1]}, {POISON[2]}) passe l'oracle : "
      f"{'ADMISSIBLE' if POISON in acc_p else 'REJETE'}")
print("  (types OK, parent hors FUNCTIONAL -> aucune contrainte ne s'y oppose)")

# Etape 2 : inference sur le KG empoisonne. On applique les regles DEJA APPRISES
# (le `rules` de la cellule mine_rules) -- c'est l'etape d'inference d'un pipeline
# reel : on apprend les regles une fois, puis on les applique aux nouveaux faits.
# (NB : re-miner sur le seul fait empoisonne diluerait la confiance -- le poison
# ajoute des chaines parent o parent sans fait grandparent correspondant -- et
# ferait tomber la regle sous le seuil. Ce serait une autre lecon, pas celle-ci.)
poison_facts = kg_facts + [POISON]
kg_p, derived_p = forward_chain(poison_facts, rules)

# Etape 3 : on mesure le DOMMAGE reel. On compare a la derivation SANS poison
# (le `derived` de la cellule forward_chain). Tout fait derive avec le poison
# mais absent sans poison est ATTRIBUABLE au poison (cascade comprise : un fait
# contamine nourrit a son tour d'autres regles). Les DIRECTS sont ceux dont le
# poison est un temoin immediat.
clean_set = {f for (f, _rule, _wit) in derived}
tainted = [(f, rule, wit) for (f, rule, wit) in derived_p if f not in clean_set]
directs = [(f, rule, wit) for (f, rule, wit) in tainted
           if any(tuple(w) == POISON for w in wit)]

print(f"\nSans poison : {len(derived)} fait(s) nouveau(x).")
print(f"Avec poison : {len(derived_p)} fait(s) nouveau(x).")
print(f"DOMMAGE attribuable au poison : {len(tainted)} fait(s) contamines, dont")
print(f"{len(directs)} ont le poison comme TEMOIN DIRECT ; les {len(tainted) - len(directs)} autres")
print("sont des effets de CASCADE (un fait contamine nourrit une autre regle).")

print("\nEntrees directes du poison et leur provenance :")
for (r, s, o), rule, wit in directs:
    print(f"  {r}({s}, {o})")
    print(f"    regle   : {rule['text']}  (conf {rule['conf']:.2f})")
    print(f"    temoins : " + ", ".join(f"{w[0]}({w[1]}, {w[2]})" for w in wit))

print(f"\nTotal des {len(tainted)} faits contamines (directs + cascade) :")
for (r, s, o), _rule, _wit in sorted(tainted):
    print(f"  {r}({s}, {o})")

print()
print("Lecture : UNE seule phrase fausse mais bien typee provoque une CASCADE de")
print("faits faux par chainage avant -- via des regles ELLES-MEMES VRAIES (la")
print("regle grandparent :- parent o parent est correcte). Le poison traverse car")
print("parent n'est pas fonctionnel et lucas/sophie ont les bons types ; pire, les")
print("faits contamines alimentent d'autres regles (dirige, travaille_pour) et")
print("l'effet s'amplifie. Le dommage reel (" + str(len(tainted)) + " faits) depasse")
print("les entrees directes (" + str(len(directs)) + ") : c'est le cout d'un chainage")
print("avant sans re-validation.")
print()
print("Quelle etape aurait PU l'arreter ?")
print("  - L'oracle NON : parent(lucas, sophie) est bien type et non-fonctionnel.")
print("  - Le mineur NON : la regle grandparent est genuinement vraie.")
print("  - Le chainage avant NON : il est sound, il applique des regles vraies.")
print("  => Le seul verrou possible est SCHEMA : une contrainte de CARDINALITE")
print("     (un enfant a au plus 2 parents : sophie a deja claire, il faudrait")
print("     connaitre un 2e parent legitime pour bloquer lucas), ou d'ACYCLICITE")
print("     / generation (lucas est lui-meme petit-enfant d'henri : il ne peut pas")
print("     etre ancetre de sophie, aussi descendante d'henri). Sans ces")
print("     contraintes le pipeline est aveugle au sens. La variation implemente la")
print("     defense par cardinalite.")
Le poison parent(lucas, sophie) passe l'oracle : ADMISSIBLE
  (types OK, parent hors FUNCTIONAL -> aucune contrainte ne s'y oppose)

Sans poison : 3 fait(s) nouveau(x).
Avec poison : 12 fait(s) nouveau(x).
DOMMAGE attribuable au poison : 9 fait(s) contamines, dont
4 ont le poison comme TEMOIN DIRECT ; les 5 autres
sont des effets de CASCADE (un fait contamine nourrit une autre regle).

Entrees directes du poison et leur provenance :
  dirige(lucas, atelier_verne)
    regle   : dirige(X,Y) :- parent(X,Z), travaille_pour(Z,Y)  (conf 0.67)
    temoins : parent(lucas, sophie), travaille_pour(sophie, atelier_verne)
  grandparent(marc, sophie)
    regle   : grandparent(X,Y) :- parent(X,Z), parent(Z,Y)  (conf 0.67)
    temoins : parent(marc, lucas), parent(lucas, sophie)
  grandparent(nadia, sophie)
    regle   : grandparent(X,Y) :- parent(X,Z), parent(Z,Y)  (conf 0.67)
    temoins : parent(nadia, lucas), parent(lucas, sophie)
  travaille_pour(lucas, atelier_verne)
    regle   : travaille_pour(X,Y) :- parent(X,Z), travaille_pour(Z,Y)  (conf 0.67)
    temoins : parent(lucas, sophie), travaille_pour(sophie, atelier_verne)

Total des 9 faits contamines (directs + cascade) :
  dirige(lucas, atelier_verne)
  dirige(marc, atelier_verne)
  dirige(nadia, atelier_verne)
  dirige(paul, atelier_verne)
  grandparent(marc, sophie)
  grandparent(nadia, sophie)
  travaille_pour(lucas, atelier_verne)
  travaille_pour(nadia, atelier_verne)
  travaille_pour(paul, atelier_verne)

Lecture : UNE seule phrase fausse mais bien typee provoque une CASCADE de
faits faux par chainage avant -- via des regles ELLES-MEMES VRAIES (la
regle grandparent :- parent o parent est correcte). Le poison traverse car
parent n'est pas fonctionnel et lucas/sophie ont les bons types ; pire, les
faits contamines alimentent d'autres regles (dirige, travaille_pour) et
l'effet s'amplifie. Le dommage reel (9 faits) depasse
les entrees directes (4) : c'est le cout d'un chainage
avant sans re-validation.

Quelle etape aurait PU l'arreter ?
  - L'oracle NON : parent(lucas, sophie) est bien type et non-fonctionnel.
  - Le mineur NON : la regle grandparent est genuinement vraie.
  - Le chainage avant NON : il est sound, il applique des regles vraies.
  => Le seul verrou possible est SCHEMA : une contrainte de CARDINALITE
     (un enfant a au plus 2 parents : sophie a deja claire, il faudrait
     connaitre un 2e parent legitime pour bloquer lucas), ou d'ACYCLICITE
     / generation (lucas est lui-meme petit-enfant d'henri : il ne peut pas
     etre ancetre de sophie, aussi descendante d'henri). Sans ces
     contraintes le pipeline est aveugle au sens. La variation implemente la
     defense par cardinalite.

Exercice 4 (variation) : Bloquer le poison (cardinalite des parents)

Implémentez la contrainte de CARDINALITÉ qui aurait bloqué le poison : un enfant a au plus 2 parents. Re-injectez le poison après les 2 parents légitimes de Sophie et montrez qu’il est rejeté.

Étapes : 1. Étendre l’oracle : compter les parents déjà admis pour chaque enfant ; rejeter le 3e. 2. Ajouter un 2e parent légitime à Sophie (ex. le père thomas), puis injecter parent(lucas, sophie) — lucas est le 3e parent → rejeté. 3. Que reste-t-il non couvert par cette contrainte ? (Indice : un empoisonnement par une seule affirmation sur un enfant sans parent connu.)

Indice : la cardinalité borne le nombre d’attaques simultanées, mais ne prouve pas la véracité. C’est une défense en profondeur, pas une preuve.

# Exercice 4 (variation) : Bloquer le poison (cardinalite des parents)
# TODO etudiant : implémentez la contrainte "un enfant a au plus 2 parents".

# Etape 1 : oracle_cardinalite(triples, max_parents=2) : rejeter le (max+1)e parent
# def oracle_cardinalite(triples, max_parents=2):
#     ...

# Etape 2 : sophie a deja claire + thomas (2e parent legitime), puis lucas -> 3e
# flux = kg_facts + [("parent", "thomas", "sophie"), ("parent", "lucas", "sophie")]

# Etape 3 : que reste-t-il non couvert ? (poison sur enfant SANS parent connu)
print("Exercice a completer : bloquer le poison par cardinalite")
print("Etape 1 : oracle_cardinalite (au plus 2 parents par enfant)")
print("Etape 2 : lucas = 3e parent de sophie -> rejet")
print("Etape 3 : quel poison passe encore malgre la cardinalite ?")

# Indice : la cardinalite borne le nombre d'attaques simultanees, mais ne prouve
# pas la veracite. Defense en profondeur, pas preuve.
# result = None  # TODO etudiant : lucas rejete ?
Exercice a completer : bloquer le poison par cardinalite
Etape 1 : oracle_cardinalite (au plus 2 parents par enfant)
Etape 2 : lucas = 3e parent de sophie -> rejet
Etape 3 : quel poison passe encore malgre la cardinalite ?

10. Conclusion

Ce que le pipeline demontre

  1. La division du travail neuro-symbolique fonctionne : le LLM a lu le texte, l’oracle a tue les deux pieges, le mineur a retrouve la definition du grand-parent, le chainage a decouvert un fait que personne n’avait ecrit — avec sa preuve.

  2. Chaque etage a son angle mort : l’extraction depend du schema et du modèle ; l’oracle tranche les conflits par anciennete ; le mineur confond regularite et causalite ; l’inference propage les artefacts avec la même assurance que les decouvertes — et re-injecte ses conclusions sans les re-soumettre a l’oracle (deux dirigeants pour l’Atelier Verne !). Le pipeline n’est pas une forteresse, c’est une chaîne de responsabilites — et la provenance est son registre.

  3. La serie en un tableau :

Etage du pipeline Outil Herite de
Schema / biais de langage vocabulaire ferme, signatures SL-4, SL-5
Extraction LLM + parsing + filets SL-9
Oracle de consistance types, contraintes, conflits SL-1, SL-3
Graphe de connaissances index relationnel SL-8
Mining de règles support, confiance, seuils SL-8, SL-5
Chainage + provenance règles compilees, forward SL-2
Hybridation, audit LLM vs raisonneur SL-7, SL-9
(manquant : requêtes actives) poser LA bonne question SL-10, defi Ex. 4

Pour aller plus loin

Le pipeline jouet de ce notebook a des cousins industriels : les systèmes d’extraction d’information neuronale + validation par schema (NELL, Google Knowledge Vault), les mineurs de règles sur KG massifs (AMIE+ sur Wikidata), et la generation augmentee par graphe (GraphRAG). Les ingredients sont les mêmes ; seules les echelles changent.


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 (etendre le schema) Le mineur va decouvrir marie_avec(X,Y) :- marie_avec(Y,X) avec confiance 1.0 : une règle qui ne fait que refleter votre propre completion d’oracle. Comment distinguer mecaniquement une règle apprise des données d’une règle qui reapprend le schema qu’on y a injecte ?
Ex. 2 (politique de conflit) Votre politique a sources suppose les fiabilites connues a priori. En truth discovery, on les estime en même temps que les faits (une source est fiable si elle affirme des faits confirmes…). Esquissez l’algorithme de point fixe correspondant et son cas pathologique.
Ex. 3 (seuils) grandparent :- parent o parent (vraie) et travaille_pour :- parent o travaille_pour (fausse) ont la même confiance 0.67 : aucun seuil ne les separe. Quelle information NON presente dans le KG les separerait (contre-exemples ? intervention ? texte ?) — et lequel des dix notebooks de la serie fournit l’outil le plus proche ?
Ex. 4 (empoisonnement) Classez les defenses par etage (modalite epistemique a l’extraction, contraintes temporelles a l’oracle, pedigree a l’inference) et defendez la these : « la provenance est la seule defense qui passe a l’echelle ». Quelle requête d’apprentissage actif (SL-10) le pipeline devrait-il poser pour trancher travaille_pour(henri, atelier_verne) ?

Ressources

  • A. Carlson et al., Toward an Architecture for Never-Ending Language Learning (NELL), AAAI 2010
  • X. Dong et al., Knowledge Vault: A Web-Scale Approach to Probabilistic Knowledge Fusion, KDD 2014
  • L. Galarraga et al., Fast Rule Mining in Ontological Knowledge Bases with AMIE+, VLDB Journal 2015
  • Y. Li et al., Truth Discovery with Multiple Conflicting Information Providers, TKDE 2008
  • D. Edge et al., From Local to Global: A GraphRAG Approach to Query-Focused Summarization, 2024
  • Russell & Norvig, AI: A Modern Approach, 3e ed., chap. 19 (le fil de toute la serie)

Retour : Index SymbolicLearning | << SL-10

Retour au sommet