File size: 11,485 Bytes
e1b8452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
"""Tests Sprint 23 — intégrité anti-hallucination du moteur narratif.

Le Sprint 23 ferme le trou méthodologique laissé par le Sprint 19 : le test
de traçabilité des nombres dans la synthèse rendue tolérait une whitelist
``{"95", "100"}`` de littéraux non-traçables au payload. Cette whitelist
est désormais vide ; toute valeur numérique apparaissant dans la synthèse
doit provenir du ``Fact.payload`` d'un détecteur.

Ce module vérifie quatre choses :

1. Les payloads des détecteurs concernés (``CONFIDENCE_WARNING``,
   ``PARETO_ALTERNATIVE``, ``COST_OUTLIER``) exposent bien les nouveaux
   champs (``confidence_level``, ``cost_unit_pages``).
2. Les templates FR/EN ne contiennent plus les littéraux ``95`` ni ``1000``
   en dehors d'un placeholder ``{...}``.
3. Le test de traçabilité reste vert avec une whitelist vide.
4. La stabilité du bootstrap est testée : deux seeds produisent des bornes
   d'IC à ±0,5 pp pour ``n=20`` documents — garantit que l'IC affiché
   dans le rapport est représentatif (sinon il faudrait passer
   ``n_iter=5000``).
5. Le pipeline narratif EN bout-en-bout produit des phrases anglaises
   bien formées (pas de placeholder non substitué) sur fixtures réalistes.
6. ``select_facts`` accepte un ``type_order`` custom et le respecte.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

from picarones.core.narrative import (
    Fact,
    FactImportance,
    FactType,
    build_synthesis,
    select_facts,
)
from picarones.core.narrative.arbiter import DEFAULT_TYPE_ORDER
from picarones.core.statistics import bootstrap_ci

ROOT = Path(__file__).parent.parent
TEMPLATES_DIR = ROOT / "picarones" / "core" / "narrative" / "templates"


# ---------------------------------------------------------------------------
# Fixtures locales — minimum viable pour faire émettre chaque détecteur
# ---------------------------------------------------------------------------

def _full_data() -> dict:
    """Données qui déclenchent ``CONFIDENCE_WARNING`` (IC large) et le Pareto."""
    return {
        "ranking": [
            {"engine": "A", "mean_cer": 0.05, "wer": 0.10},
            {"engine": "B", "mean_cer": 0.06, "wer": 0.12},
            {"engine": "C", "mean_cer": 0.20, "wer": 0.30},
        ],
        "n_documents": 20,
        "statistics": {
            "bootstrap_cis": [
                # IC large pour A → confidence_warning
                {"engine": "A", "mean": 0.05, "ci_lower": 0.01, "ci_upper": 0.15},
                {"engine": "B", "mean": 0.06, "ci_lower": 0.05, "ci_upper": 0.07},
                {"engine": "C", "mean": 0.20, "ci_lower": 0.18, "ci_upper": 0.22},
            ],
        },
        "pareto": {
            "cost": {
                "front": ["A", "B"],
                "points": [
                    {"engine": "A", "cer": 0.05, "cost": 50.0},
                    {"engine": "B", "cer": 0.06, "cost": 5.0},  # alternative pas chère
                    {"engine": "C", "cer": 0.20, "cost": 300.0},  # cost outlier
                ],
            },
        },
    }


# ---------------------------------------------------------------------------
# 1. Payloads exposent les nouveaux champs
# ---------------------------------------------------------------------------

class TestPayloadsCarryFormerlyHardcodedConstants:
    def test_confidence_warning_payload_carries_confidence_level(self):
        from picarones.core.narrative.detectors import detect_confidence_warning

        facts = detect_confidence_warning(_full_data())
        assert facts, "fixture devrait déclencher au moins un confidence_warning"
        for f in facts:
            assert f.payload.get("confidence_level") == 95, (
                "Le seuil 95 doit être propagé dans le payload "
                "(plus de littéral hardcodé dans le template)."
            )

    def test_pareto_alternative_payload_carries_cost_unit(self):
        from picarones.core.narrative.detectors import detect_pareto_alternative

        facts = detect_pareto_alternative(_full_data())
        assert facts, "fixture devrait déclencher au moins un pareto_alternative"
        for f in facts:
            assert f.payload.get("cost_unit_pages") == 1000

    def test_cost_outlier_payload_carries_cost_unit(self):
        from picarones.core.narrative.detectors import detect_cost_outlier

        facts = detect_cost_outlier(_full_data())
        assert facts, "fixture devrait déclencher au moins un cost_outlier"
        for f in facts:
            assert f.payload.get("cost_unit_pages") == 1000


# ---------------------------------------------------------------------------
# 2. Les templates ne hardcodent plus les littéraux 95 et 1000
# ---------------------------------------------------------------------------

# Toute occurrence d'un nombre HORS d'un placeholder ``{...}`` est
# considérée comme un littéral hardcodé. On scanne en remplaçant d'abord
# tous les placeholders par un marqueur neutre.
_PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
_NUMBER_RE = re.compile(r"\b\d+\b")


def _strip_placeholders(template: str) -> str:
    return _PLACEHOLDER_RE.sub("PLACEHOLDER", template)


@pytest.mark.parametrize("lang", ["fr", "en"])
class TestTemplatesNoHardcodedLiterals:
    def test_no_hardcoded_95(self, lang):
        import yaml

        path = TEMPLATES_DIR / f"{lang}.yaml"
        templates = yaml.safe_load(path.read_text(encoding="utf-8"))
        for key, tpl in templates.items():
            stripped = _strip_placeholders(tpl)
            numbers = _NUMBER_RE.findall(stripped)
            assert "95" not in numbers, (
                f"Template {lang}/{key} contient encore le littéral 95 ; "
                "doit utiliser {confidence_level}."
            )
            assert "1000" not in numbers, (
                f"Template {lang}/{key} contient encore le littéral 1000 ; "
                "doit utiliser {cost_unit_pages}."
            )


# ---------------------------------------------------------------------------
# 3. Pipeline complet produit une synthèse traçable, whitelist vide
# ---------------------------------------------------------------------------

class TestEndToEndWithEmptyWhitelist:
    @pytest.mark.parametrize("lang", ["fr", "en"])
    def test_synthesis_renders_without_unsubstituted_placeholders(self, lang):
        result = build_synthesis(_full_data(), lang)
        for sentence in result["sentences"]:
            assert "{" not in sentence and "}" not in sentence, (
                f"Placeholder non substitué dans la synthèse {lang} : {sentence!r}"
            )

    @pytest.mark.parametrize("lang", ["fr", "en"])
    def test_every_number_traceable_with_empty_whitelist(self, lang):
        from picarones.core.narrative import extract_numbers

        from tests.test_sprint19_narrative_engine import _numbers_in_payload

        result = build_synthesis(_full_data(), lang)
        allowed: set[str] = set()
        for f in result["facts"]:
            allowed |= _numbers_in_payload(f.get("payload", {}))

        unknown: list[tuple[str, str]] = []
        for sentence in result["sentences"]:
            for num in extract_numbers(sentence):
                num_norm = num.replace(",", ".")
                if num_norm not in allowed:
                    unknown.append((num, sentence))
        assert not unknown, (
            f"[{lang}] Nombres non traçables au payload : {unknown}"
        )


# ---------------------------------------------------------------------------
# 4. Stabilité du bootstrap entre seeds
# ---------------------------------------------------------------------------

class TestBootstrapStabilityAcrossSeeds:
    """Vérifie que ``bootstrap_ci`` à n_iter=1000 est suffisamment stable.

    Pour 20 documents avec un CER moyen ~5 %, l'écart entre deux seeds sur
    chacune des bornes (lower, upper) doit rester inférieur à 0,5 point de
    pourcentage de CER (= 0.005 en absolu). Si ce test échoue à l'avenir,
    cela signifie qu'il faut passer à ``n_iter=5000`` pour fiabiliser
    l'IC affiché dans le rapport.
    """

    def test_bootstrap_stable_for_typical_cer_distribution(self):
        # 20 valeurs de CER autour de 5 % — distribution réaliste.
        values = [
            0.02, 0.03, 0.04, 0.04, 0.045, 0.05, 0.05, 0.05, 0.055, 0.055,
            0.06, 0.06, 0.06, 0.065, 0.07, 0.07, 0.075, 0.08, 0.085, 0.10,
        ]
        lo1, hi1 = bootstrap_ci(values, n_iter=1000, seed=42)
        lo2, hi2 = bootstrap_ci(values, n_iter=1000, seed=7)
        assert abs(lo1 - lo2) < 0.005, (
            f"Borne basse instable entre seeds (Δ = {abs(lo1 - lo2):.4f}) ; "
            "envisager n_iter=5000."
        )
        assert abs(hi1 - hi2) < 0.005, (
            f"Borne haute instable entre seeds (Δ = {abs(hi1 - hi2):.4f}) ; "
            "envisager n_iter=5000."
        )

    def test_bootstrap_strictly_deterministic_same_seed(self):
        values = [0.01, 0.05, 0.1, 0.2]
        a = bootstrap_ci(values, n_iter=1000, seed=42)
        b = bootstrap_ci(values, n_iter=1000, seed=42)
        assert a == b, "Bootstrap doit être bit-à-bit reproductible sur seed identique."


# ---------------------------------------------------------------------------
# 5. select_facts respecte un type_order custom
# ---------------------------------------------------------------------------

class TestSelectFactsCustomTypeOrder:
    def _make_facts(self) -> list[Fact]:
        return [
            Fact(
                type=FactType.GLOBAL_LEADER_CER,
                importance=FactImportance.HIGH,
                payload={"engine": "A"},
                engines_involved=("A",),
            ),
            Fact(
                type=FactType.SPEED_WINNER,
                importance=FactImportance.HIGH,
                payload={"engine": "B"},
                engines_involved=("B",),
            ),
            Fact(
                type=FactType.PARETO_ALTERNATIVE,
                importance=FactImportance.HIGH,
                payload={"engine": "C"},
                engines_involved=("C",),
            ),
        ]

    def test_default_order_puts_global_leader_first(self):
        selected = select_facts(self._make_facts(), max_facts=3)
        assert selected[0].type == FactType.GLOBAL_LEADER_CER

    def test_custom_order_promotes_speed_winner(self):
        custom = (
            FactType.SPEED_WINNER,
            FactType.GLOBAL_LEADER_CER,
            FactType.PARETO_ALTERNATIVE,
        ) + tuple(t for t in DEFAULT_TYPE_ORDER if t not in {
            FactType.SPEED_WINNER,
            FactType.GLOBAL_LEADER_CER,
            FactType.PARETO_ALTERNATIVE,
        })
        selected = select_facts(self._make_facts(), max_facts=3, type_order=custom)
        assert selected[0].type == FactType.SPEED_WINNER, (
            "Avec un type_order custom plaçant SPEED_WINNER en premier, "
            "il doit ressortir avant GLOBAL_LEADER_CER à importance égale."
        )

    def test_unknown_types_in_custom_order_fall_to_end(self):
        # Un type_order réduit (ne mentionne que GLOBAL_LEADER_CER) ; les autres
        # types sont relégués à la fin sans crash.
        custom = (FactType.GLOBAL_LEADER_CER,)
        selected = select_facts(self._make_facts(), max_facts=3, type_order=custom)
        assert selected[0].type == FactType.GLOBAL_LEADER_CER
        assert len(selected) == 3