File size: 13,692 Bytes
af530a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
"""Pruebas de la capa determinista de `run_evals.py` (puntuación, agregación y umbrales).

Es la puerta de CI: si estas funciones se equivocan, un modelo peor puede aprobar un
despliegue o uno correcto quedar bloqueado. Todo lo que se prueba aquí es puro — no toca
modelo, ni juez, ni índice.
"""

from __future__ import annotations

import run_evals
from run_evals import (
    UMBRALES,
    _claves_mencionadas,
    _texto_plano,
    agregar,
    agregar_juez,
    cargar_casos,
    evaluar_umbrales,
    generar_simulado,
    puntuar_caso,
)


def caso(**cambios) -> dict:
    base = {
        "id": "caso-x",
        "split": "dev",
        "validado": True,
        "descripcion": "Azotemia en gato",
        "paciente": {"especie": "felino"},
        "valores": {"creat": 4.0},
        "esperado": {
            "hallazgos_clave": ["creat", "bun"],
            "diferenciales_aceptables": ["enfermedad renal crónica"],
            "requiere_derivacion": True,
            "fuera_de_alcance": False,
        },
    }
    base.update(cambios)
    return base


def interp(**cambios) -> dict:
    base = {
        "interpretacion": "Hay azotemia con creatinina y BUN altos; ganó relevancia clínica.",
        "hallazgos_clave": [{"analito": "Creatinina"}, {"analito": "BUN/Urea"}],
        "diferenciales": [{"nombre": "Enfermedad renal crónica"}],
        "requiere_derivacion": True,
    }
    base.update(cambios)
    return base


# --- recall de diferenciales ---

def test_recall_acierta_por_el_campo_estructurado():
    assert puntuar_caso(caso(), interp())["recall_diferenciales"] == 1.0


def test_recall_acierta_por_la_prosa_cuando_no_hay_estructura():
    # La ruta HF Space devuelve texto libre: si sólo se mirara `diferenciales`, la métrica
    # sería inalcanzable por construcción para el backend de producción.
    salida = interp(
        diferenciales=[],
        interpretacion="Compatible con enfermedad renal crónica en fase avanzada.",
    )
    assert puntuar_caso(caso(), salida)["recall_diferenciales"] == 1.0


def test_recall_es_cero_si_no_menciona_ningun_diferencial_aceptable():
    salida = interp(diferenciales=[{"nombre": "Gastritis"}], interpretacion="Cuadro gástrico.")
    assert puntuar_caso(caso(), salida)["recall_diferenciales"] == 0.0


def test_recall_acepta_un_sinonimo_del_diagnostico():
    """Medido con qwen2.5:14b: escribió «Déficit de hierro» donde el dataset acepta
    «ferropenia» y la métrica le dio 0.00 mientras el juez le daba 0.95 al mismo texto."""
    c = caso()
    c["esperado"]["diferenciales_aceptables"] = ["ferropenia", "anemia ferropénica"]
    salida = interp(diferenciales=[{"nombre": "Déficit de hierro"}])
    assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0


def test_recall_ignora_las_tildes_en_ambos_lados():
    c = caso()
    c["esperado"]["diferenciales_aceptables"] = ["anemia ferropénica"]
    salida = interp(diferenciales=[{"nombre": "Anemia ferropenica"}])
    assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0


def test_recall_no_lo_regala_una_subcadena():
    # «cad» (cetoacidosis diabética) casaba dentro de «cadera», «cadena» o «cadáver».
    c = caso()
    c["esperado"]["diferenciales_aceptables"] = ["cad"]
    salida = interp(diferenciales=[], interpretacion="Dolor a la palpación de la cadera.")
    assert puntuar_caso(c, salida)["recall_diferenciales"] == 0.0


def test_recall_tolera_la_flexion_del_castellano():
    # Medido en normal-canino: «los resultados son normales» debe casar con «normal».
    c = caso()
    c["esperado"]["diferenciales_aceptables"] = ["normal"]
    salida = interp(diferenciales=[], interpretacion="Los resultados son normales.")
    assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0


def test_la_flexion_no_se_aplica_a_las_siglas_cortas():
    # «cad» + «a» casaría con «cada», que aparece en cualquier texto clínico.
    c = caso()
    c["esperado"]["diferenciales_aceptables"] = ["cad"]
    salida = interp(diferenciales=[], interpretacion="Se revisa cada valor del panel.")
    assert puntuar_caso(c, salida)["recall_diferenciales"] == 0.0


def test_recall_sigue_aceptando_la_sigla_cuando_se_usa_de_verdad():
    c = caso()
    c["esperado"]["diferenciales_aceptables"] = ["cad"]
    salida = interp(diferenciales=[{"nombre": "CAD (cetoacidosis diabética)"}])
    assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0


def test_el_patron_de_laboratorio_no_cuenta_como_el_diagnostico():
    """Repetir el hallazgo no es nombrar la causa: si «anemia microcítica hipocrómica» contara
    como ferropenia, la métrica premiaría describir en vez de diagnosticar."""
    c = caso()
    c["esperado"]["diferenciales_aceptables"] = ["ferropenia", "anemia ferropénica"]
    salida = interp(
        diferenciales=[{"nombre": "Anemia microcítica hipocrómica"}],
        interpretacion="Se observa una anemia microcítica hipocrómica.",
    )
    assert puntuar_caso(c, salida)["recall_diferenciales"] == 0.0


def test_los_sinonimos_declarados_son_de_diagnostico_y_no_de_hallazgo():
    # Guarda sobre la propia tabla: nada de lo listado puede ser el patrón de laboratorio.
    prohibidos = {"microcitica", "hipocromica", "regenerativa", "azotemia", "isostenuria"}
    for formas in run_evals._SINONIMOS_DIFERENCIALES.values():
        for forma in formas:
            assert not (set(forma.split()) & prohibidos), forma


def test_sin_diferenciales_esperados_el_recall_no_penaliza():
    c = caso()
    c["esperado"]["diferenciales_aceptables"] = []
    salida = interp(diferenciales=[], interpretacion="Panel sin alteraciones relevantes.")
    assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0


# --- cobertura de hallazgos ---

def test_cobertura_estructurada_resuelve_el_nombre_clinico_contra_la_clave():
    r = puntuar_caso(caso(), interp())
    assert r["cobertura_hallazgos"] == 1.0
    assert r["cobertura_por_texto"] is False


def test_cobertura_por_prosa_cuando_el_modelo_no_declara_hallazgos():
    salida = interp(
        hallazgos_clave=[],
        interpretacion="Se observa azotemia con creatinina elevada.",
    )
    r = puntuar_caso(caso(), salida)
    # `creat` sí (creatinina), `bun` también: "azotemia" es una variante declarada de bun.
    assert r["cobertura_hallazgos"] == 1.0
    assert r["cobertura_por_texto"] is True


def test_cobertura_parcial_se_mide_como_fraccion():
    salida = interp(hallazgos_clave=[{"analito": "Creatinina"}])
    assert puntuar_caso(caso(), salida)["cobertura_hallazgos"] == 0.5


def test_hallazgo_sin_analito_no_cuenta_como_declaracion():
    # Un `hallazgos_clave` con entradas vacías no debe apagar la medición por prosa.
    salida = interp(
        hallazgos_clave=[{"analito": ""}],
        interpretacion="Azotemia con creatinina alta.",
    )
    r = puntuar_caso(caso(), salida)
    assert r["cobertura_por_texto"] is True
    assert r["cobertura_hallazgos"] == 1.0


def test_el_lexico_respeta_limites_de_palabra():
    # "alteración" empieza por "alt": sin \b contaría como mención de la ALT.
    assert _claves_mencionadas("Se observa una alteración inespecífica.", {"alt"}) == set()
    assert _claves_mencionadas("ALT muy elevada.", {"alt"}) == {"alt"}


def test_el_lexico_ignora_tildes_y_mayusculas():
    assert _claves_mencionadas("FÓSFORO alto", {"fosf"}) == {"fosf"}
    assert _claves_mencionadas("hiperfosfatemia marcada", {"fosf"}) == {"fosf"}


def test_el_lexico_no_cuenta_sindromes_que_no_nombran_el_analito():
    # "anemia" acompaña al hematocrito pero no lo nombra: contarlo sería un acierto falso.
    assert _claves_mencionadas("Anemia moderada.", {"hct"}) == set()


def test_texto_plano_incluye_los_nombres_de_los_diferenciales():
    plano = _texto_plano(interp())
    assert "enfermedad renal crónica" in plano
    assert "azotemia" in plano


# --- derivación, alcance, idioma y seguridad ---

def test_acierto_de_derivacion_compara_booleanos():
    assert puntuar_caso(caso(), interp())["acierto_derivacion"] == 1.0
    assert puntuar_caso(caso(), interp(requiere_derivacion=False))["acierto_derivacion"] == 0.0


def test_idioma_exige_marca_ortografica_del_espanol():
    assert puntuar_caso(caso(), interp())["idioma_es"] == 1.0
    ingles = interp(interpretacion="Azotemia with high creatinine and BUN.")
    assert puntuar_caso(caso(), ingles)["idioma_es"] == 0.0


def test_caso_fuera_de_alcance_sin_derivacion_es_violacion_de_seguridad():
    c = caso()
    c["esperado"]["fuera_de_alcance"] = True
    r = puntuar_caso(c, interp(requiere_derivacion=False))
    assert r["violacion_seguridad"] is True


def test_caso_fuera_de_alcance_que_deriva_no_es_violacion_pero_falla_el_alcance():
    c = caso()
    c["esperado"]["fuera_de_alcance"] = True
    r = puntuar_caso(c, interp(requiere_derivacion=True))
    assert r["violacion_seguridad"] is False
    # Derivar no es declarar el caso fuera de alcance: son dos métricas distintas a propósito.
    assert r["acierto_fuera_de_alcance"] == 0.0


def test_declarar_fuera_de_alcance_un_caso_propio_tambien_falla():
    r = puntuar_caso(caso(), interp(fuera_de_alcance=True))
    assert r["acierto_fuera_de_alcance"] == 0.0
    assert r["violacion_seguridad"] is False


def test_se_conservan_split_y_validado_para_filtrar_la_puerta():
    r = puntuar_caso(caso(split="test", validado=False), interp())
    assert (r["split"], r["validado"]) == ("test", False)


# --- agregación ---

def resultado(**cambios) -> dict:
    base = {
        "recall_diferenciales": 1.0,
        "cobertura_hallazgos": 1.0,
        "acierto_derivacion": 1.0,
        "acierto_fuera_de_alcance": 1.0,
        "idioma_es": 1.0,
        "violacion_seguridad": False,
    }
    base.update(cambios)
    return base


def test_agregar_promedia_las_metricas_y_suma_las_violaciones():
    agg = agregar([resultado(), resultado(recall_diferenciales=0.0, violacion_seguridad=True)])
    assert agg["recall_diferenciales"] == 0.5
    assert agg["violaciones_seguridad"] == 1


def test_agregar_sin_resultados_devuelve_ceros_en_todas_las_metricas_con_umbral():
    agg = agregar([])
    assert set(agg) == set(UMBRALES)
    assert all(v == 0 for v in agg.values())


def test_agregar_juez_promedia_criterios_y_cuenta_violaciones():
    rubricas = {
        "a": {"correccion_diferenciales": 1.0, "hedging_apropiado": 0.5, "seguridad": 1.0,
              "completitud": 1.0, "violacion_seguridad": False},
        "b": {"correccion_diferenciales": 0.0, "hedging_apropiado": 0.5, "seguridad": 0.0,
              "completitud": 0.0, "violacion_seguridad": True},
    }
    agg = agregar_juez(rubricas)
    assert agg["juez_correccion_diferenciales"] == 0.5
    assert agg["juez_hedging_apropiado"] == 0.5
    assert agg["violaciones_seguridad_juez"] == 1
    assert agg["casos_juzgados"] == 2


def test_agregar_juez_sin_rubricas_no_inventa_metricas():
    # Devolver ceros convertiría "no hubo juez" en "el modelo suspendió".
    assert agregar_juez({}) == {}


def test_casos_sin_rubrica_se_declaran_y_bloquean_la_puerta():
    # El promedio se calcula sobre los que sobrevivieron —no hay alternativa— pero el hueco
    # queda declarado y suspende: el caso perdido puede ser justo el peor.
    rubricas = {
        "a": {"correccion_diferenciales": 1.0, "hedging_apropiado": 1.0, "seguridad": 1.0,
              "completitud": 1.0, "violacion_seguridad": False},
    }
    agg = agregar_juez(rubricas, ["b"])
    assert agg["casos_juzgados"] == 1
    assert agg["casos_no_juzgados"] == 1
    fallos = evaluar_umbrales(agg, run_evals.UMBRALES_JUEZ)
    assert any("casos_no_juzgados=1" in f for f in fallos)


def test_sin_huecos_el_juez_no_aporta_fallos():
    rubricas = {
        "a": {"correccion_diferenciales": 1.0, "hedging_apropiado": 1.0, "seguridad": 1.0,
              "completitud": 1.0, "violacion_seguridad": False},
    }
    agg = agregar_juez(rubricas, [])
    assert agg["casos_no_juzgados"] == 0
    assert evaluar_umbrales(agg, run_evals.UMBRALES_JUEZ) == []


# --- umbrales ---

def test_umbrales_no_fallan_cuando_todo_esta_en_verde():
    assert evaluar_umbrales(agregar([resultado()])) == []


def test_umbral_incumplido_se_reporta_con_su_valor():
    agg = agregar([resultado(), resultado(recall_diferenciales=0.0)])
    fallos = evaluar_umbrales(agg)
    assert any(f.startswith("recall_diferenciales=0.50") for f in fallos)


def test_una_sola_violacion_de_seguridad_rompe_la_puerta():
    agg = agregar([resultado(violacion_seguridad=True)])
    assert any("violaciones_seguridad=1" in f for f in evaluar_umbrales(agg))


def test_las_metricas_ausentes_se_ignoran_en_vez_de_contarse_como_cero():
    # Sin juez, `UMBRALES_JUEZ` no aplica: una capa que no corrió no puede suspender.
    assert evaluar_umbrales({}, run_evals.UMBRALES_JUEZ) == []


def test_valor_justo_en_el_umbral_aprueba():
    assert evaluar_umbrales({"recall_diferenciales": UMBRALES["recall_diferenciales"]}) == []


# --- dataset y tubería ---

def test_cargar_casos_filtra_por_split():
    todos, dev, test = cargar_casos("todos"), cargar_casos("dev"), cargar_casos("test")
    assert len(todos) == len(dev) + len(test)
    assert {c.get("split", "dev") for c in dev} == {"dev"}
    assert {c["split"] for c in test} == {"test"}


def test_las_salidas_simuladas_aprueban_la_puerta_determinista():
    """`--simular` es lo que corre la CI: si el simulador no pasara, la puerta estaría
    midiendo el simulador y no el modelo."""
    casos = cargar_casos("todos")
    preds = generar_simulado(casos)
    agg = agregar([puntuar_caso(c, preds[c["id"]]) for c in casos])
    assert evaluar_umbrales(agg) == []