expAge
fix(inference): désactiver target_generic et double_isa — induction logiquement invalide
41328a9
"""Moteur d'inférence JDM — cascade de schémas, bornée par budget (Phase 11).
`infer(client, subject, relation, object, *, effort=...)` essaie une cascade
de schémas d'inférence (du moins cher au plus cher) et s'arrête au premier
qui conclut. Renvoie un `InferenceResult` dont `signed_weight` porte le
verdict : > 0 vrai, < 0 faux/réfuté, 0 silence.
Inspiré du moteur PHP `infer_answer` — adapté : typé, structuré, et surtout
**borné** (un `LookupBudget` coupe net pour rester « pas trop gourmand »).
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from jdm_agent.client import JDMClient
from jdm_agent.inference.budget import BudgetExhausted, LookupBudget
from jdm_agent.inference.constants import (
BUDGET_BY_EFFORT,
COMPOSITION_MAP,
DEFAULT_MAX_DEPTH,
DEFAULT_TOP_K,
IMPLICATION_MAP,
INVERSE_RELATIONS,
REFUTATION_SCAN,
RELATION_PHRASES,
SCHEMA_LABELS,
STRONG_SUPPORT_W,
TRANSITIVE_RELATIONS,
)
from jdm_agent.inference.graph import (
display,
edge_weight,
generics,
norm,
outgoing,
topk_positive,
)
from jdm_agent.inference.models import FiredSchema, InferenceResult, ProofStep
# ---------- Contexte d'une inférence ----------
@dataclass
class _Ctx:
client: JDMClient
budget: LookupBudget
subject: str
relation: str
object: str
top_k: int
max_depth: int
effort: int
mem: dict = field(default_factory=dict)
# Helpers liés au contexte (consomment le budget via graph.py).
def _out(ctx: _Ctx, term: str, rel: str):
return outgoing(ctx.client, ctx.budget, ctx.mem, term, rel)
def _ew(ctx: _Ctx, src: str, rel: str, tgt: str) -> float:
return edge_weight(ctx.client, ctx.budget, ctx.mem, src, rel, tgt)
def _disp(ctx: _Ctx, name: str) -> str:
return display(ctx.client, name)
def _gens(ctx: _Ctx, term: str, k: int | None = None):
return generics(ctx.client, ctx.budget, ctx.mem, term, k or ctx.top_k)
# ---------- Construction du résultat ----------
# Facteur de soundness par schéma : la confiance finale est pondérée par ce
# facteur. Les schémas SAINS (transitivité, inverse, déduction-ISA) gardent
# une confiance pleine ; les schémas LÂCHES (synonymie, association, double-ISA)
# sont délibérément décotés — la substitution par synonyme/association n'est
# pas une équivalence stricte (ex. « pénis r_syn sexe » est en réalité une
# hyperonymie : on doit donc rester prudent sur ce type de déduction).
SCHEMA_CONFIDENCE: dict[FiredSchema, float] = {
FiredSchema.TAUTOLOGY: 1.00,
FiredSchema.CONTRADICTION: 1.00,
FiredSchema.INVERSE: 1.00,
FiredSchema.IMPLICATION: 0.95,
FiredSchema.ISA_INCOMPATIBLE: 0.95,
FiredSchema.CLASS_ELIM: 0.90,
FiredSchema.ANTONYM_CONTRAST: 0.88,
FiredSchema.COHYPONYM: 0.85,
FiredSchema.DEDUCTION_ISA: 0.90,
FiredSchema.TRANSITIVITY: 0.90,
FiredSchema.GEO_PROPAGATION: 0.88,
FiredSchema.HYPONYM_PROP: 0.85,
FiredSchema.PREFIX: 0.85,
FiredSchema.COMPOSITION: 0.80,
FiredSchema.SYNONYM_EQUIV: 0.70,
FiredSchema.TARGET_GENERIC: 0.60,
FiredSchema.DOUBLE_ISA: 0.55,
}
# Mots de négation par premier mot de la locution (rendu naturel).
_NEG_FIRST: dict[str, str] = {
"est": "n'est", "a": "n'a", "peut": "ne peut", "fait": "ne fait",
"sert": "ne sert", "se": "ne se", "subit": "ne subit",
"utilise": "n'utilise", "relève": "ne relève", "caractérise": "ne caractérise",
"produit": "ne produit", "évoque": "n'évoque",
}
def _negate_phrase(phrase: str) -> str:
"""Met une locution relationnelle à la forme négative (rendu naturel)."""
words = phrase.split(" ", 1)
first, rest = words[0], (words[1] if len(words) > 1 else "")
neg = _NEG_FIRST.get(first, "ne " + first)
return f"{neg} pas {rest}".strip()
def _natural_chain(proof: list[ProofStep]) -> str:
"""Rend la chaîne de preuve en français lisible — pas de codes r_xxx.
Ex. [moineau r_isa oiseau ; oiseau r_can_eat graine] →
« moineau » est un type d'« oiseau », et « oiseau » peut manger « graine »
"""
parts: list[str] = []
for s in proof:
phrase = RELATION_PHRASES.get(s.relation, f"est en relation ({s.relation}) avec")
if s.w < 0:
phrase = _negate_phrase(phrase)
parts.append(f"« {s.source} » {phrase} « {s.target} »")
return ", et ".join(parts)
def _make_result(ctx: _Ctx, weight: float, schema: FiredSchema,
proof: list[ProofStep], explanation: str | None = None,
consensus: int = 1) -> InferenceResult:
"""Assemble l'InferenceResult : confiance normalisée + explication FR.
Confiance = tanh(|w|/W) · décote longueur de chaîne · facteur de soundness
du schéma · bonus de consensus. Un schéma lâche donne une confiance
honnêtement plus basse ; à l'inverse, `consensus` > 1 (plusieurs chemins
indépendants concordants — cf. agrégation multi-chemins) la rehausse.
"""
factor = SCHEMA_CONFIDENCE.get(schema, 0.70)
consensus_factor = min(1.30, 1.0 + 0.12 * max(0, consensus - 1))
conf = round(min(1.0,
math.tanh(abs(weight) / STRONG_SUPPORT_W)
* (0.9 ** max(0, len(proof) - 1))
* factor
* consensus_factor,
), 3)
if explanation is None:
# Explication en langage naturel : chaîne de raisonnement lisible,
# sans codes de relations (« r_syn »), avec le schéma en clair.
chain = _natural_chain(proof)
label = SCHEMA_LABELS.get(schema.value, schema.value)
verdict = "Oui, déductible" if weight > 0 else "Non, réfuté"
extra = f", {consensus} chemins concordants" if consensus > 1 else ""
explanation = f"{verdict} ({label}{extra}) : {chain}."
return InferenceResult(
subject=ctx.subject, relation=ctx.relation, object=ctx.object,
signed_weight=float(weight), fired_schema=schema, proof=proof,
confidence=conf, explanation=explanation,
)
# ---------- Schémas d'inférence (effort 1) ----------
def _schema_guards(ctx: _Ctx) -> InferenceResult | None:
"""subject == object : tautologie (relations réflexives) ou contradiction."""
if norm(ctx.subject) != norm(ctx.object):
return None
if ctx.relation == "r_anto":
return _make_result(
ctx, -STRONG_SUPPORT_W, FiredSchema.CONTRADICTION, [],
explanation="Non — un terme n'est pas l'antonyme de lui-même.",
)
if ctx.relation in ("r_isa", "r_syn", "r_hypo", "r_associated", "r_similar"):
return _make_result(
ctx, STRONG_SUPPORT_W, FiredSchema.TAUTOLOGY, [],
explanation=f"Oui — trivialement, « {ctx.subject} » entretient "
f"{ctx.relation} avec lui-même.",
)
return None
def _schema_prefix(ctx: _Ctx) -> InferenceResult | None:
"""« saucisse de Toulouse » r_isa « saucisse » — composé préfixé."""
if ctx.relation not in ("r_isa", "r_associated"):
return None
s, o = norm(ctx.subject), norm(ctx.object)
if s != o and o and (s + " ").startswith(o + " "):
return _make_result(
ctx, 30.0, FiredSchema.PREFIX, [],
explanation=f"Oui — « {ctx.subject} » est un composé lexical "
f"préfixé par « {ctx.object} ».",
)
return None
def _schema_inverse(ctx: _Ctx) -> InferenceResult | None:
"""Relation inverse : `(object, R⁻¹, subject)` répond pour `(subject, R, object)`."""
inv = INVERSE_RELATIONS.get(ctx.relation)
if not inv:
return None
w = _ew(ctx, ctx.object, inv, ctx.subject)
if w == 0:
return None
proof = [ProofStep(source=_disp(ctx, ctx.object), relation=inv,
target=_disp(ctx, ctx.subject), w=w,
note=f"inverse de {ctx.relation}")]
return _make_result(ctx, w, FiredSchema.INVERSE, proof)
def _schema_implication(ctx: _Ctx) -> InferenceResult | None:
"""Implication : une relation plus spécifique R' implique la relation demandée."""
for impl_rel in IMPLICATION_MAP.get(ctx.relation, []):
w = _ew(ctx, ctx.subject, impl_rel, ctx.object)
if w > 0:
proof = [ProofStep(source=_disp(ctx, ctx.subject), relation=impl_rel,
target=_disp(ctx, ctx.object), w=w,
note=f"implique {ctx.relation}")]
return _make_result(ctx, w, FiredSchema.IMPLICATION, proof)
return None
def _schema_antonym_contrast(ctx: _Ctx) -> InferenceResult | None:
"""Réfutation par contraste antonymique : `A R X` ∧ `X r_anto B` ⟹ A R B faux.
Si A possède déjà une propriété X et que la cible B est l'antonyme de X,
le triplet est réfuté. Ex. `feu r_carac chaud` (présent) + `chaud r_anto
froid` ⟹ `feu r_carac froid` = non. Pertinent pour les relations de
propriété (r_carac, r_has_prop). Coût : 2 lookups (intersection en mémoire).
"""
if ctx.relation not in ("r_carac", "r_has_prop"):
return None
a_vals = {norm(n): (n, w) for n, w, _ in
topk_positive(_out(ctx, ctx.subject, ctx.relation), ctx.top_k)}
if not a_vals:
return None
for aname, aw, _rid in _out(ctx, ctx.object, "r_anto"):
if aw > 0 and norm(aname) in a_vals and norm(aname) != norm(ctx.object):
xn, xw = a_vals[norm(aname)]
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation=ctx.relation,
target=_disp(ctx, xn), w=xw),
ProofStep(source=_disp(ctx, ctx.object), relation="r_anto",
target=_disp(ctx, xn), w=aw, note="antonyme"),
]
return _make_result(ctx, -min(xw, aw), FiredSchema.ANTONYM_CONTRAST, proof)
return None
def _schema_cohyponym(ctx: _Ctx) -> InferenceResult | None:
"""Réfutation par cohyponymie : A et B partagent un hyperonyme ⟹ A r_isa B faux.
Ne concerne que `r_isa`. Si A et B ont un hyperonyme commun et qu'aucun
n'est l'hyperonyme de l'autre, ce sont des FRÈRES — `A r_isa B` est faux.
Ex. `chat r_isa mammifère`, `chien r_isa mammifère` ⟹ `chat r_isa chien`
= non. Placé APRÈS les schémas ISA positifs : si B était un hyperonyme
(même transitif) de A, la transitivité l'aurait déjà confirmé.
"""
if ctx.relation != "r_isa":
return None
sub_h = {norm(h): (h, w) for h, w, _ in
topk_positive(_out(ctx, ctx.subject, "r_isa"), ctx.top_k)}
obj_h = {norm(h): (h, w) for h, w, _ in
topk_positive(_out(ctx, ctx.object, "r_isa"), ctx.top_k)}
# Si l'un subsume l'autre, ce ne sont pas des cohyponymes.
if norm(ctx.object) in sub_h or norm(ctx.subject) in obj_h:
return None
common = sorted(set(sub_h) & set(obj_h))
if common:
g = common[0]
gh, gw = sub_h[g]
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation="r_isa",
target=_disp(ctx, gh), w=gw),
ProofStep(source=_disp(ctx, ctx.object), relation="r_isa",
target=_disp(ctx, gh), w=obj_h[g][1],
note="hyperonyme commun — A et B sont frères"),
]
return _make_result(ctx, -30.0, FiredSchema.COHYPONYM, proof)
return None
def _schema_synonym_equiv(ctx: _Ctx) -> InferenceResult | None:
"""Via un synonyme de l'object : `object r_syn S` ∧ `subject R S`."""
syns = topk_positive(_out(ctx, ctx.object, "r_syn"), ctx.top_k)
for sname, sw, _rid in syns:
if norm(sname) in (norm(ctx.subject), norm(ctx.object)):
continue
w = _ew(ctx, ctx.subject, ctx.relation, sname)
if w != 0:
proof = [
ProofStep(source=_disp(ctx, ctx.object), relation="r_syn",
target=_disp(ctx, sname), w=sw, note="synonyme"),
ProofStep(source=_disp(ctx, ctx.subject), relation=ctx.relation,
target=_disp(ctx, sname), w=w),
]
return _make_result(ctx, w, FiredSchema.SYNONYM_EQUIV, proof)
return None
def _schema_deduction_isa(ctx: _Ctx) -> InferenceResult | None:
"""Déduction par généralisation : `A r_isa G` ∧ `G R B` ⟹ `A R B`.
Le schéma le plus rentable — un trait porté par un générique se transfère.
AGRÉGATION MULTI-CHEMINS : on n'arrête PAS au 1er générique concluant, on
parcourt tous ceux déjà récupérés (même coût d'appels, ils sont en cache).
Une négation héritée prime sur une affirmation concurrente ; plusieurs
chemins concordants rehaussent la confiance (consensus).
"""
hits: list[tuple[str, float, str, float]] = [] # (gname, gw, via, w)
for gname, gw, via in _gens(ctx, ctx.subject):
if norm(gname) == norm(ctx.object):
continue
w = _ew(ctx, gname, ctx.relation, ctx.object)
if w != 0:
hits.append((gname, gw, via, w))
if not hits:
return None
negs = [h for h in hits if h[3] < 0]
poss = [h for h in hits if h[3] > 0]
# La négation curée prime sur une affirmation générique concurrente.
pool = negs if negs else poss
gname, gw, via, w = max(pool, key=lambda h: abs(h[3]))
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation=via,
target=_disp(ctx, gname), w=gw),
ProofStep(source=_disp(ctx, gname), relation=ctx.relation,
target=_disp(ctx, ctx.object), w=w),
]
return _make_result(ctx, w, FiredSchema.DEDUCTION_ISA, proof,
consensus=len(pool))
def _schema_geo_propagation(ctx: _Ctx) -> InferenceResult | None:
"""Propagation géographique : `A r_lieu L` ∧ `L r_holo B` ⟹ `A r_lieu B`.
Complète la transitivité de r_lieu en suivant la chaîne de CONTENANCE
géographique (un lieu fait partie d'un lieu plus grand). Ex.
`X r_lieu Paris` ∧ `Paris r_holo France` ⟹ `X r_lieu France`.
"""
if ctx.relation != "r_lieu":
return None
places = topk_positive(_out(ctx, ctx.subject, "r_lieu"), ctx.top_k)
for lname, lw, _rid in places:
if norm(lname) in (norm(ctx.subject), norm(ctx.object)):
continue
w = _ew(ctx, lname, "r_holo", ctx.object)
if w > 0:
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation="r_lieu",
target=_disp(ctx, lname), w=lw),
ProofStep(source=_disp(ctx, lname), relation="r_holo",
target=_disp(ctx, ctx.object), w=w,
note="contenu géographiquement"),
]
return _make_result(ctx, w, FiredSchema.GEO_PROPAGATION, proof)
return None
def _schema_transitivity(ctx: _Ctx) -> InferenceResult | None:
"""Transitivité (relations transitives) : `A R X` ∧ `X R B` ⟹ `A R B`.
AGRÉGATION MULTI-CHEMINS : on parcourt tous les intermédiaires (déjà en
cache) ; plusieurs chemins concordants rehaussent la confiance.
"""
if ctx.relation not in TRANSITIVE_RELATIONS:
return None
mids = topk_positive(_out(ctx, ctx.subject, ctx.relation), ctx.top_k)
hits: list[tuple[str, float, float]] = [] # (mname, mw, w)
for mname, mw, _rid in mids:
if norm(mname) in (norm(ctx.subject), norm(ctx.object)):
continue
w = _ew(ctx, mname, ctx.relation, ctx.object)
if w > 0:
hits.append((mname, mw, w))
if not hits:
return None
mname, mw, w = max(hits, key=lambda h: h[2])
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation=ctx.relation,
target=_disp(ctx, mname), w=mw),
ProofStep(source=_disp(ctx, mname), relation=ctx.relation,
target=_disp(ctx, ctx.object), w=w),
]
return _make_result(ctx, w, FiredSchema.TRANSITIVITY, proof,
consensus=len(hits))
def _schema_isa_incompatible(ctx: _Ctx) -> InferenceResult | None:
"""Réfutation : `A r_isa H` ∧ `H r_isa-incompatible B` ⟹ A n'est pas B."""
if ctx.relation != "r_isa":
return None
# On scrute REFUTATION_SCAN hyperonymes (le vrai générique peut être noyé
# dans le bruit JDM — cf. baleine→mammifère au rang ~25).
hyps = topk_positive(_out(ctx, ctx.subject, "r_isa"), REFUTATION_SCAN)
for hname, hw, _rid in hyps:
if ">" in hname: # refinement — pas de r_isa-incompatible attachée
continue
w = _ew(ctx, hname, "r_isa-incompatible", ctx.object)
if w > 0:
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation="r_isa",
target=_disp(ctx, hname), w=hw),
ProofStep(source=_disp(ctx, hname), relation="r_isa-incompatible",
target=_disp(ctx, ctx.object), w=w),
]
return _make_result(ctx, -abs(w), FiredSchema.ISA_INCOMPATIBLE, proof)
return None
def _schema_class_elim(ctx: _Ctx) -> InferenceResult | None:
"""Réfutation par HÉRITAGE NÉGATIF : `A r_isa H` ∧ `H R B` explicitement nié.
Scanne TOUS les hyperonymes de A (pas seulement les grandes classes) : si
l'un d'eux nie explicitement la relation vers B (w < 0), A en hérite. Une
négation JDM est un signal curé délibéré — elle prime sur une déduction
positive concurrente. Capture les contrastifs de genre :
`chatte r_isa femelle` ∧ `femelle r_has_part pénis = -24` ⟹ réfuté.
Doit donc tourner AVANT deduction_isa (qui sinon conclurait « vrai » via
un hyperonyme générique comme `chat r_has_part pénis = +72`).
"""
hyps = topk_positive(_out(ctx, ctx.subject, "r_isa"), REFUTATION_SCAN)
hyp_w = {norm(h): w for h, w, _ in hyps}
# (a) un hyperonyme nie directement la relation vers la cible.
for hname, hw, _rid in hyps:
if norm(hname) == norm(ctx.object):
continue
w = _ew(ctx, hname, ctx.relation, ctx.object)
if w < 0:
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation="r_isa",
target=_disp(ctx, hname), w=hw),
ProofStep(source=_disp(ctx, hname), relation=ctx.relation,
target=_disp(ctx, ctx.object), w=w, note="négation"),
]
return _make_result(ctx, w, FiredSchema.CLASS_ELIM, proof)
# (b) la négation est stockée sur la relation INVERSE : `object R⁻¹ H' < 0`
# avec H' un hyperonyme du sujet. Ex. `prostate r_holo femme = -171`
# ⟹ une femme n'a pas de prostate (un seul lookup, pas N).
inv = INVERSE_RELATIONS.get(ctx.relation)
if inv:
for hname, hw, _rid in _out(ctx, ctx.object, inv):
if hw < 0 and norm(hname) in hyp_w and norm(hname) != norm(ctx.object):
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation="r_isa",
target=_disp(ctx, hname), w=hyp_w[norm(hname)]),
ProofStep(source=_disp(ctx, ctx.object), relation=inv,
target=_disp(ctx, hname), w=hw, note="négation (inverse)"),
]
return _make_result(ctx, hw, FiredSchema.CLASS_ELIM, proof)
return None
def _schema_hyponym_propagation(ctx: _Ctx) -> InferenceResult | None:
"""Propagation par hyponymie : `A R H` ∧ `H r_isa B` ⟹ `A R B`.
Sain : si A entretient la relation avec un cas PARTICULIER de B, il
l'entretient avec B (plus général). Ex. `oiseau r_can_eat graine` +
`graine r_isa nourriture` ⟹ `oiseau r_can_eat nourriture`.
"""
targets = topk_positive(_out(ctx, ctx.subject, ctx.relation), ctx.top_k)
for tname, tw, _rid in targets:
if norm(tname) in (norm(ctx.subject), norm(ctx.object)):
continue
isa_w = _ew(ctx, tname, "r_isa", ctx.object)
if isa_w > 0:
w = min(tw, isa_w)
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation=ctx.relation,
target=_disp(ctx, tname), w=tw),
ProofStep(source=_disp(ctx, tname), relation="r_isa",
target=_disp(ctx, ctx.object), w=isa_w,
note="cas particulier de la cible"),
]
return _make_result(ctx, w, FiredSchema.HYPONYM_PROP, proof)
return None
# ---------- Schémas d'inférence (effort 2) ----------
def _schema_composition(ctx: _Ctx) -> InferenceResult | None:
"""Composition : `A R2 C` ∧ `C R3 B` ⟹ `A R B` (cartes curées)."""
for r2, r3 in COMPOSITION_MAP.get(ctx.relation, []):
for cname, cw, _rid in topk_positive(_out(ctx, ctx.subject, r2), ctx.top_k):
if norm(cname) in (norm(ctx.subject), norm(ctx.object)):
continue
w = _ew(ctx, cname, r3, ctx.object)
if w > 0:
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation=r2,
target=_disp(ctx, cname), w=cw),
ProofStep(source=_disp(ctx, cname), relation=r3,
target=_disp(ctx, ctx.object), w=w),
]
return _make_result(ctx, w, FiredSchema.COMPOSITION, proof)
return None
def _schema_double_isa(ctx: _Ctx) -> InferenceResult | None:
"""Double-ISA : `A r_isa X` ∧ `B r_isa Y` ∧ `X R Y` ⟹ `A R B`."""
gens_s = _gens(ctx, ctx.subject, min(ctx.top_k, 5))
gens_o = _gens(ctx, ctx.object, min(ctx.top_k, 5))
for gs, gsw, vs in gens_s:
for go, gow, vo in gens_o:
if norm(gs) == norm(go):
continue
w = _ew(ctx, gs, ctx.relation, go)
if w != 0:
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation=vs,
target=_disp(ctx, gs), w=gsw),
ProofStep(source=_disp(ctx, gs), relation=ctx.relation,
target=_disp(ctx, go), w=w),
ProofStep(source=_disp(ctx, ctx.object), relation=vo,
target=_disp(ctx, go), w=gow, note="généralisation"),
]
return _make_result(ctx, w, FiredSchema.DOUBLE_ISA, proof)
return None
def _schema_target_generic(ctx: _Ctx) -> InferenceResult | None:
"""Via un générique de l'object : `subject R G` ∧ `object r_isa G`."""
for gname, gw, via in _gens(ctx, ctx.object):
if norm(gname) == norm(ctx.subject):
continue
w = _ew(ctx, ctx.subject, ctx.relation, gname)
if w != 0:
proof = [
ProofStep(source=_disp(ctx, ctx.subject), relation=ctx.relation,
target=_disp(ctx, gname), w=w),
ProofStep(source=_disp(ctx, ctx.object), relation=via,
target=_disp(ctx, gname), w=gw, note="généralisation"),
]
return _make_result(ctx, w, FiredSchema.TARGET_GENERIC, proof)
return None
# Cascade effort 1. ORDRE CRITIQUE (early-exit au 1er schéma concluant) :
# 1. schémas gratuits / exacts : guards, prefix, inverse, implication
# 2. RÉFUTATIONS spécialisées : isa_incompatible, class_elim
# 3. schémas SAINS porteurs de signe : deduction_isa, transitivity,
# hyponym_propagation — ils peuvent conclure « vrai » OU « faux ».
# 4. réfutation tardive par cohyponymie.
# La SYNONYMIE (schéma synonym_equiv et r_syn dans les chemins de
# généralisation) a été DÉSACTIVÉE volontairement : la synonymie JDM n'est
# pas substituable (ex. « pénis r_syn sexe » est en fait une hyperonymie),
# elle générait trop de faux positifs. Le schéma `_schema_synonym_equiv`
# reste défini mais ne figure plus dans la cascade.
_EFFORT1_SCHEMAS = (
_schema_guards,
_schema_prefix,
_schema_inverse,
_schema_implication,
# Réfutations spécialisées (avant les déductions).
_schema_isa_incompatible,
_schema_class_elim,
_schema_antonym_contrast,
# Déductions saines.
_schema_deduction_isa,
_schema_transitivity,
_schema_geo_propagation,
_schema_hyponym_propagation,
# Réfutation tardive : cohyponymie — uniquement si aucun chemin ISA
# positif n'a abouti (sinon la transitivité aurait confirmé).
_schema_cohyponym,
)
# Effort 2 : composition seule (curée, saine).
#
# Les schémas `_schema_target_generic` et `_schema_double_isa` ont été
# DÉSACTIVÉS définitivement : ils faisaient de l'INDUCTION (spécialisation
# vers le bas de l'arbre r_isa), pas de la déduction.
#
# Exemple du bug qu'ils produisaient :
# « chaise r_has_part coussin » + « coussin en cuir r_isa coussin »
# ⟹ FAUX « chaise r_has_part coussin en cuir »
#
# La cible (« coussin en cuir ») est PLUS SPÉCIFIQUE que ce qu'on sait
# (« coussin »), donc on ne peut RIEN en déduire — c'est l'erreur d'affirmation
# du conséquent. Décoter la confiance d'un schéma logiquement faux ne le rend
# pas vrai. La direction valide (spécifique → général) est déjà capturée par
# `_schema_hyponym_propagation`. Les fonctions restent définies pour
# rétro-compatibilité d'imports mais ne tournent plus.
_EFFORT2_SCHEMAS = (
_schema_composition,
)
# ---------- Point d'entrée ----------
def infer(client: JDMClient, subject: str, relation: str, object: str, *,
effort: int = 1, budget: int | None = None,
max_depth: int = DEFAULT_MAX_DEPTH,
top_k: int = DEFAULT_TOP_K) -> InferenceResult:
"""Infère si le triplet `(subject, relation, object)` est vrai selon JDM.
Ne refait PAS le lookup direct exact du triplet demandé (cf. `verify_claim`
qui s'en charge avant) — les schémas n'examinent que des triplets dérivés.
Args:
client: JDMClient.
subject, relation, object: le triplet à inférer (relation = nom JDM r_xxx).
effort: 1 = schémas noyau (rapide) ; 2 = + schémas étendus.
budget: plafond d'appels HTTP. Si None, dérivé de l'effort.
max_depth: profondeur max (réservé pour les schémas multi-sauts).
top_k: nb de génériques/intermédiaires explorés par schéma.
Returns:
`InferenceResult` — `signed_weight` > 0 vrai, < 0 faux, 0 silence.
`lookups_used` indique le coût réel.
"""
effort = 2 if effort >= 2 else 1
limit = budget if budget is not None else BUDGET_BY_EFFORT[effort]
bdg = LookupBudget(limit)
ctx = _Ctx(
client=client, budget=bdg,
subject=subject, relation=relation, object=object,
top_k=top_k, max_depth=max_depth, effort=effort,
)
schemas = list(_EFFORT1_SCHEMAS)
if effort >= 2:
schemas += list(_EFFORT2_SCHEMAS)
result: InferenceResult | None = None
try:
for schema_fn in schemas:
r = schema_fn(ctx)
if r is not None and r.signed_weight != 0:
result = r
break
except BudgetExhausted:
# On a épuisé le budget sans conclure — silence propre.
result = None
if result is None:
result = InferenceResult(
subject=subject, relation=relation, object=object,
signed_weight=0.0, fired_schema=FiredSchema.NONE,
confidence=0.0, explanation="",
)
result.lookups_used = bdg.used
return result