File size: 10,498 Bytes
5c1d1c7
7e3f910
5c1d1c7
7e3f910
5c1d1c7
 
 
 
 
 
 
 
 
 
7e3f910
 
 
5c1d1c7
7e3f910
 
 
 
5c1d1c7
7e3f910
ad1f162
 
7e3f910
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a26b99c
 
7e3f910
 
ad1f162
5c1d1c7
7e3f910
 
 
 
 
a26b99c
 
ad1f162
a26b99c
 
7e3f910
 
 
 
 
 
 
a26b99c
7e3f910
 
 
75faa96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c1d1c7
 
75faa96
5c1d1c7
 
a26b99c
 
5c1d1c7
 
 
 
 
75faa96
 
 
 
5c1d1c7
 
 
 
 
 
a26b99c
5939d6b
 
 
 
 
 
 
a26b99c
7e3f910
 
 
 
 
 
5c1d1c7
 
a26b99c
 
7e3f910
a26b99c
7e3f910
 
 
a26b99c
7e3f910
 
75faa96
 
 
7e3f910
75faa96
5c1d1c7
75faa96
 
5c1d1c7
 
 
75faa96
 
 
 
 
5c1d1c7
 
 
 
7e3f910
 
 
a26b99c
7e3f910
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c1d1c7
75faa96
 
5c1d1c7
7e3f910
75faa96
 
 
7e3f910
5c1d1c7
 
 
 
 
7e3f910
 
5c1d1c7
 
 
 
 
 
 
75faa96
 
 
 
 
 
5c1d1c7
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
"""Vérifie une `Claim` contre le graphe JDM.

Modèle « contenance vs inférence » (Phase 11), piloté par `effort` :

  * **effort = 0** — CONTENANCE pure. On ne répond que sur ce que JDM
    contient littéralement : le triplet exact `(subject, relation, object)`.
    Absent = UNKNOWN. Aucune déduction. C'est la « vue du réseau ».
  * **effort = 1** — direct d'abord ; si JDM est silencieux, repli sur le
    moteur d'inférence (schémas noyau). « Le fait est-il vrai / déductible ? »
  * **effort = 2** — idem avec la cascade d'inférence complète.

Un verdict inféré est TOUJOURS marqué (`Verdict.inference_schema` non nul) et
son explication précise que JDM ne contient pas directement le triplet — on
ne confond jamais contenance et déduction.
"""
from __future__ import annotations

import math
from typing import Optional

from jdm_agent.client import JDMClient
from jdm_agent.factcheck.models import Claim, Evidence, Status, Verdict
from jdm_agent.inference import DEFAULT_MAX_DEPTH

# Constante de calibration utilisée UNIQUEMENT pour normaliser la confiance via
# `tanh(|w| / STRONG_SUPPORT_W)`. Pas un seuil de filtrage — un facteur d'échelle.
STRONG_SUPPORT_W = 100.0


def _to_evidence(client: JDMClient, source: str, rel: str, target: str, w: float) -> Evidence:
    """Convertit un triplet bas niveau en Evidence (avec décodage refinement)."""
    src_dec = client.decode_node_name(source)
    tgt_dec = client.decode_node_name(target)
    return Evidence(
        source=src_dec["decoded"],
        relation=rel,
        target=tgt_dec["decoded"],
        w=w,
        source_id=source if src_dec["is_refinement"] else None,
        target_id=target if tgt_dec["is_refinement"] else None,
    )


def _norm(s: str) -> str:
    """Normalisation simple pour matcher des noms (casse, espaces)."""
    return s.strip().lower()


def _matches(target_name: str, expected: str) -> bool:
    return _norm(target_name) == _norm(expected)


def _relations_from_by_type(client: JDMClient, subject: str, relation_name: str,
                            min_weight: Optional[float] = None,
                            limit: int = 500) -> list[tuple[str, float, int]]:
    """Helper : liste les triplets sortants de subject pour ce type de relation.

    Phase 9b : aucun seuil hardcodé. Si l'appelant ne précise pas min_weight,
    on ne transmet pas le filtre à JDM. Renvoie tuples (name, w, rel_id).
    """
    rid = client.relation_type_id(relation_name)
    if rid is None:
        return []
    try:
        res = client.relations_from(
            subject, types_ids=[rid],
            min_weight=min_weight,
            limit=limit,
        )
    except Exception:
        return []
    idx = res.node_index()
    out = []
    for r in res.relations:
        n = idx.get(r.node2)
        if n is not None:
            out.append((n.name, r.w, r.id))
    return out


def _build_direct_verdict(client: JDMClient, claim: Claim,
                          direct_hit: tuple) -> Verdict:
    """Construit le verdict de CONTENANCE pour un triplet trouvé en direct."""
    name, w, rid = direct_hit
    jdm_says_yes = w > 0
    status = Status.SUPPORTED if (jdm_says_yes == claim.polarity) else Status.CONTRADICTED
    conf = round(math.tanh(abs(w) / STRONG_SUPPORT_W), 3)

    try:
        annotations = client.get_annotations_for_triplet(rid)
    except Exception:
        annotations = []
    annot_str = ""
    if annotations:
        tops = ", ".join(f"{a.value} (w={a.w:.0f})" for a in annotations[:3])
        annot_str = f" Annotations JDM : {tops}."
    exceptions = [a for a in annotations if a.kind == "exception"]
    if exceptions:
        exc_str = ", ".join(a.value for a in exceptions[:3])
        annot_str += f" Exception(s) annotée(s) : {exc_str}."

    ev = _to_evidence(client, claim.subject, claim.relation, name, w)
    explanation = (
        f"JDM contient directement le triplet "
        f"`{claim.subject} | {claim.relation} | {ev.target}` avec poids "
        f"{w:.0f} ({'affirmation' if jdm_says_yes else 'négation'} consensuelle).{annot_str}"
    )
    return Verdict(
        claim=claim, status=status, confidence=conf,
        evidence_for=[ev] if status == Status.SUPPORTED else [],
        evidence_against=[ev] if status == Status.CONTRADICTED else [],
        explanation=explanation,
    )


def verify_claim(client: JDMClient, claim: Claim, *,
                 effort: int = 0,
                 bypass_containment: bool = False,
                 budget: Optional[int] = None,
                 max_depth: int = DEFAULT_MAX_DEPTH) -> Verdict:
    """Vérifie une claim atomique contre JDM. Pas d'appel LLM.

    Args:
        client: JDMClient.
        claim: la claim à vérifier.
        effort: 0 = contenance pure (triplet exact uniquement) ; 1 = repli
            inférence noyau si JDM est silencieux ; 2 = inférence complète.
        bypass_containment: si True (et effort ≥ 1), lance l'inférence MÊME si
            le triplet est déjà présent directement dans JDM — utile pour voir
            la chaîne de déduction d'un fait pourtant déjà connu. Par défaut
            False : un triplet contenu directement court-circuite l'inférence.
        budget: plafond d'appels HTTP pour l'inférence (None = défaut par effort).
        max_depth: profondeur max de l'inférence.

    Returns:
        `Verdict`. Si le verdict vient de l'inférence, `inference_schema` est
        renseigné et `inference_proof` détaille la chaîne de déduction.
    """
    # Résout les formes « molles » de raffinement (avocat>juriste →
    # avocat>116477>66699) pour que la requête tombe sur le bon nœud JDM.
    rs = client.resolve_term(claim.subject)
    ro = client.resolve_term(claim.object)
    if rs != claim.subject or ro != claim.object:
        claim = claim.model_copy(update={"subject": rs, "object": ro})

    triples = _relations_from_by_type(client, claim.subject, claim.relation)
    if not triples and not client.relation_type_id(claim.relation):
        return Verdict(
            claim=claim, status=Status.UNKNOWN, confidence=0.0,
            explanation=f"Relation inconnue dans JDM : {claim.relation!r}.",
        )

    # --- 1) Lookup DIRECT : le triplet exact existe-t-il ? (signe ±) ----------
    # C'est la « contenance » — la seule chose vraie à effort 0.
    direct_hit = None  # tuple (name, w, rel_id)
    for name, w, rid in triples:
        if _matches(name, claim.object):
            direct_hit = (name, w, rid)
            break
        dec = client.decode_node_name(name)
        if dec["is_refinement"] and _matches(dec["decoded"], claim.object):
            direct_hit = (name, w, rid)
            break

    # Contenance directe — court-circuite l'inférence SAUF si bypass demandé.
    if direct_hit is not None and not bypass_containment:
        return _build_direct_verdict(client, claim, direct_hit)

    # --- 2) Repli (ou bypass) INFÉRENCE (effort >= 1) ------------------------
    if effort >= 1:
        inferred = _verdict_from_inference(client, claim, effort, budget, max_depth,
                                           direct_present=direct_hit is not None)
        if inferred is not None:
            return inferred

    # bypass_containment était actif mais l'inférence est muette : on ne perd
    # pas l'information de contenance directe.
    if direct_hit is not None:
        return _build_direct_verdict(client, claim, direct_hit)

    # --- 3) UNKNOWN ----------------------------------------------------------
    # JDM ne contient pas le triplet et (effort 0) ou aucune inférence n'a
    # conclu. Si beaucoup de triplets existent pour ce type, on les liste à
    # titre INDICATIF (ne constitue pas une contradiction stricte).
    if triples and len(triples) >= 5:
        top_against = [
            _to_evidence(client, claim.subject, claim.relation, n, w)
            for n, w, _rid in sorted(triples, key=lambda x: -abs(x[1]))[:5]
        ]
        return Verdict(
            claim=claim, status=Status.UNKNOWN, confidence=0.3,
            evidence_against=top_against,
            explanation=(
                f"JDM ne contient pas le triplet `{claim.subject} | {claim.relation} | {claim.object}`. "
                f"Les valeurs connues pour `{claim.subject} | {claim.relation} | ?` sont listées dans "
                f"`evidence_against` à titre indicatif (ne constituent pas une contradiction stricte)."
            ),
        )

    return Verdict(
        claim=claim, status=Status.UNKNOWN, confidence=0.0,
        explanation=f"JDM ne contient pas d'information vérifiable pour `{claim.subject} | {claim.relation} | {claim.object}`.",
    )


def _verdict_from_inference(client: JDMClient, claim: Claim, effort: int,
                            budget: Optional[int], max_depth: int,
                            direct_present: bool = False) -> Optional[Verdict]:
    """Tente un verdict par inférence. Renvoie None si l'inférence est silencieuse.

    Le verdict produit est toujours marqué (`inference_schema`). Si le triplet
    n'est pas contenu directement, l'explication le précise ; en mode bypass
    (`direct_present=True`), elle indique que c'est une inférence forcée.
    """
    from jdm_agent.inference import infer

    res = infer(client, claim.subject, claim.relation, claim.object,
                effort=effort, budget=budget, max_depth=max_depth)
    if res.is_silent:
        return None

    proof_ev = [
        Evidence(source=s.source, relation=s.relation, target=s.target, w=s.w)
        for s in res.proof
    ]
    # res.is_true => JDM permet de déduire le triplet ; sinon il le réfute.
    jdm_says_yes = res.is_true
    status = Status.SUPPORTED if (jdm_says_yes == claim.polarity) else Status.CONTRADICTED
    if direct_present:
        prefix = ("Inférence forcée (bypass) — ce triplet est aussi présent "
                  "directement dans JDM. ")
    else:
        prefix = "JDM ne contient pas directement ce triplet — verdict obtenu par inférence. "
    explanation = prefix + res.explanation
    return Verdict(
        claim=claim, status=status, confidence=res.confidence,
        evidence_for=proof_ev if status == Status.SUPPORTED else [],
        evidence_against=proof_ev if status == Status.CONTRADICTED else [],
        explanation=explanation,
        inference_schema=res.fired_schema.value,
        inference_proof=proof_ev,
    )