jdmagent / tests /test_inference.py
expAge
refactor(inference): retire le schéma assoc_bridge
d9a76ab
"""Tests du moteur d'inférence JDM (Phase 11) — JDMClient mocké, hors-ligne."""
from __future__ import annotations
from unittest.mock import MagicMock
from jdm_agent.client.models import Node, Relation, RelationsResult
from jdm_agent.factcheck import Claim, Status, verify_claim
from jdm_agent.inference import FiredSchema, infer
# Relations connues du mock (id arbitraire mais stable).
_REL_IDS: dict[str, int] = {
name: 1000 + i for i, name in enumerate([
"r_isa", "r_hypo", "r_syn", "r_anto", "r_carac", "r_carac-1",
"r_has_part", "r_holo", "r_lieu", "r_lieu-1", "r_isa-incompatible",
"r_can_eat", "r_can_eat-1", "r_has_conseq", "r_associated",
"r_has_prop",
])
}
def _make_client(data: dict[tuple[str, str], list[tuple[str, float]]]) -> MagicMock:
"""Mock JDMClient indexé par `(terme, relation)` → liste de `(cible, w)`.
Reproduit `relation_type_id`, `relations_from`, `decode_node_name`,
`get_annotations_for_triplet`. Toute relation apparaissant dans `data`
reçoit un id, même si elle n'est pas dans la liste standard `_REL_IDS`.
"""
c = MagicMock()
rel_ids = dict(_REL_IDS)
for (_term, rel) in data:
rel_ids.setdefault(rel, 2000 + len(rel_ids))
c.relation_type_id.side_effect = lambda name: rel_ids.get(name)
def fake_relations_from(term, types_ids=None, min_weight=None, limit=None):
rid = (types_ids or [None])[0]
rel_name = next((n for n, i in rel_ids.items() if i == rid), None)
rows = data.get((term, rel_name), []) if rel_name else []
nodes, relations = [], []
for j, (tgt, w) in enumerate(rows):
nid = 50_000 + j + (rid or 0)
nodes.append(Node(id=nid, name=tgt, type=1, w=int(abs(w)) or 1))
relations.append(Relation(id=j + 1, node1=1, node2=nid,
type=rid, w=float(w)))
return RelationsResult(nodes=nodes, relations=relations)
c.relations_from.side_effect = fake_relations_from
c.decode_node_name.side_effect = lambda name, **kw: {
"decoded": name, "is_refinement": False}
c.get_annotations_for_triplet.side_effect = lambda rid: []
# Résolution de raffinement / existence — passe-plat pour le mock.
c.resolve_term.side_effect = lambda term: term
c.term_exists.side_effect = lambda name: True
return c
# ---------- Schémas individuels ----------
def test_guards_tautology():
res = infer(_make_client({}), "chat", "r_syn", "chat", effort=1)
assert res.is_true
assert res.fired_schema == FiredSchema.TAUTOLOGY
def test_guards_contradiction():
res = infer(_make_client({}), "chaud", "r_anto", "chaud", effort=1)
assert res.is_false
assert res.fired_schema == FiredSchema.CONTRADICTION
def test_inverse():
c = _make_client({("graine", "r_can_eat-1"): [("moineau", 50)]})
res = infer(c, "moineau", "r_can_eat", "graine", effort=1)
assert res.is_true
assert res.fired_schema == FiredSchema.INVERSE
assert res.proof and res.proof[0].relation == "r_can_eat-1"
def test_implication():
# r_lieu-1 est impliquée par r_has_part.
c = _make_client({("chat", "r_has_part"): [("patte", 80)]})
res = infer(c, "chat", "r_lieu-1", "patte", effort=1)
assert res.is_true
assert res.fired_schema == FiredSchema.IMPLICATION
def test_deduction_isa():
c = _make_client({
("moineau", "r_isa"): [("oiseau", 200)],
("oiseau", "r_can_eat"): [("graine", 40)],
})
res = infer(c, "moineau", "r_can_eat", "graine", effort=1)
assert res.is_true
assert res.fired_schema == FiredSchema.DEDUCTION_ISA
assert len(res.proof) == 2 # moineau r_isa oiseau ; oiseau r_can_eat graine
def test_transitivity():
c = _make_client({
("voiture", "r_has_part"): [("moteur", 100)],
("moteur", "r_has_part"): [("piston", 80)],
})
res = infer(c, "voiture", "r_has_part", "piston", effort=1)
assert res.is_true
assert res.fired_schema == FiredSchema.TRANSITIVITY
def test_isa_incompatible_refutation():
c = _make_client({
("baleine", "r_isa"): [("mammifère", 300)],
("mammifère", "r_isa-incompatible"): [("poisson", 50)],
})
res = infer(c, "baleine", "r_isa", "poisson", effort=1)
assert res.is_false
assert res.fired_schema == FiredSchema.ISA_INCOMPATIBLE
def test_class_elimination_refutation():
# mammifère est une classe d'élimination ; il NIE r_carac écaille.
c = _make_client({
("baleine", "r_isa"): [("mammifère", 300)],
("mammifère", "r_carac"): [("écaille", -60)],
})
res = infer(c, "baleine", "r_carac", "écaille", effort=1)
assert res.is_false
assert res.fired_schema == FiredSchema.CLASS_ELIM
def test_hyponym_propagation():
# oiseau r_can_eat graine ; graine r_isa nourriture ⟹ oiseau r_can_eat nourriture
c = _make_client({
("oiseau", "r_can_eat"): [("graine", 100)],
("graine", "r_isa"): [("nourriture", 200)],
})
res = infer(c, "oiseau", "r_can_eat", "nourriture", effort=1)
assert res.is_true
assert res.fired_schema == FiredSchema.HYPONYM_PROP
def test_antonym_contrast_refutation():
# feu r_carac chaud ; froid r_anto chaud ⟹ feu r_carac froid réfuté
c = _make_client({
("feu", "r_carac"): [("chaud", 300)],
("froid", "r_anto"): [("chaud", 90)],
})
res = infer(c, "feu", "r_carac", "froid", effort=1)
assert res.is_false
assert res.fired_schema == FiredSchema.ANTONYM_CONTRAST
def test_cohyponym_refutation():
# chat et chien partagent l'hyperonyme mammifère ⟹ chat r_isa chien réfuté
c = _make_client({
("chat", "r_isa"): [("mammifère", 500)],
("chien", "r_isa"): [("mammifère", 500)],
})
res = infer(c, "chat", "r_isa", "chien", effort=1)
assert res.is_false
assert res.fired_schema == FiredSchema.COHYPONYM
def test_geo_propagation():
# tour Eiffel r_lieu Paris ; Paris r_holo France ⟹ tour Eiffel r_lieu France
c = _make_client({
("tour eiffel", "r_lieu"): [("Paris", 200)],
("Paris", "r_holo"): [("France", 300)],
})
res = infer(c, "tour eiffel", "r_lieu", "France", effort=1)
assert res.is_true
assert res.fired_schema == FiredSchema.GEO_PROPAGATION
def test_multipath_consensus_boosts_confidence():
# Deux génériques confirment → confiance rehaussée + mention « concordants ».
two = _make_client({
("moineau", "r_isa"): [("oiseau", 200), ("animal", 100)],
("oiseau", "r_carac"): [("petit", 50)],
("animal", "r_carac"): [("petit", 50)],
})
one = _make_client({
("moineau", "r_isa"): [("oiseau", 200)],
("oiseau", "r_carac"): [("petit", 50)],
})
r2 = infer(two, "moineau", "r_carac", "petit", effort=1)
r1 = infer(one, "moineau", "r_carac", "petit", effort=1)
assert r2.is_true and r1.is_true
assert r2.fired_schema == FiredSchema.DEDUCTION_ISA
assert r2.confidence > r1.confidence # consensus → plus confiant
assert "concordant" in r2.explanation
def test_silent_when_no_data():
res = infer(_make_client({}), "xyzzy", "r_carac", "truc", effort=1)
assert res.is_silent
assert res.fired_schema == FiredSchema.NONE
assert res.explanation == "" # pas de justification quand on ne sait pas
def test_budget_is_bounded():
# Graphe qui exigerait plusieurs lookups, mais budget = 1 → silence propre.
c = _make_client({
("a", "r_isa"): [("b", 100)],
("b", "r_carac"): [("z", 90)],
})
res = infer(c, "a", "r_carac", "z", effort=1, budget=1)
assert res.is_silent # budget épuisé avant de conclure
assert res.lookups_used <= 2 # jamais d'exception qui fuit
def test_confidence_and_proof_present():
c = _make_client({
("moineau", "r_isa"): [("oiseau", 200)],
("oiseau", "r_can_eat"): [("graine", 40)],
})
res = infer(c, "moineau", "r_can_eat", "graine", effort=1)
assert 0.0 < res.confidence <= 1.0
assert res.explanation.startswith("Oui")
# ---------- Fusion dans verify_claim ----------
def test_verify_effort0_is_pure_containment():
"""À effort 0, un triplet seulement déductible reste UNKNOWN."""
c = _make_client({
("moineau", "r_isa"): [("oiseau", 200)],
("oiseau", "r_can_eat"): [("graine", 40)],
})
claim = Claim(text="x", subject="moineau", relation="r_can_eat", object="graine")
v = verify_claim(c, claim, effort=0)
assert v.status == Status.UNKNOWN
assert v.inference_schema is None
def test_verify_effort1_uses_inference():
"""À effort 1, le même triplet est SUPPORTED par inférence."""
c = _make_client({
("moineau", "r_isa"): [("oiseau", 200)],
("oiseau", "r_can_eat"): [("graine", 40)],
})
claim = Claim(text="x", subject="moineau", relation="r_can_eat", object="graine")
v = verify_claim(c, claim, effort=1)
assert v.status == Status.SUPPORTED
assert v.inference_schema == "deduction_isa"
assert v.inference_proof # chaîne de preuve présente
def test_verify_direct_match_never_marked_inference():
"""Un verdict de contenance directe n'a jamais d'inference_schema."""
c = _make_client({("sang", "r_carac"): [("rouge", 300)]})
claim = Claim(text="x", subject="sang", relation="r_carac", object="rouge")
v = verify_claim(c, claim, effort=1)
assert v.status == Status.SUPPORTED
assert v.inference_schema is None # direct, pas inféré