# Cuando el detector debe aprender a desconfiar de sí mismo: pruebas con código de IA, tropiezos y cómo los arreglamos

Continuación del artículo anterior sobre detección de alucinaciones en código generado por IA.

En el artículo anterior contamos cómo pasamos de un registro artesanal de APIs a verificar de verdad contra los wheels de PyPI. Fue un salto grande: dejamos de depender de listas hechas a mano y empezamos a leer lo que los paquetes publican. Pero una cosa es tener la herramienta, y otra es ponerla a prueba con código de verdad, generado por modelos de verdad. Y ahí es donde uno se lleva las sorpresas.

Este artículo es sobre eso: las pruebas, las insuficiencias que encontramos, y los cinco bugs que tuvimos que arreglar para que el modo profundo (deep) fuera confiable. Spoiler: el detector funcionaba, pero se creía más listo de lo que era. Y eso, en un detector, es peligroso.

La idea: probar con código que no fuera mío

Hasta ese momento, las pruebas eran con ficheros que yo mismo había escrito (test_debe_detectar.py, test_no_debe_detectar.py, etc.). Eso está bien para validar, pero no es lo mismo que código generado por una IA. Así que le pedí a tres modelos —ChatGPT, Z.ai y otra vez ChatGPT— que generaran código Python ejecutable con alucinaciones sutiles: paquetes inventados, typos, APIs que no existen, dunders falsos, decoradores raros. Código real, no un banco de pruebas con strings.

El prompt era explícito: “código ejecutable, no un banco de pruebas, no strings que contengan código”. Y funcionó. Los tres ficheros eran código de verdad, con imports y llamadas reales, y con alucinaciones metidas con disimulo.

Los resultados, sin modo profundo

Primero los pasamos por el detector sin deep (el modo heurístico de siempre). Los resultados:

test_debe_detectar.py (corpus): 15 findings, 11 aciertos reales, 3 falsos positivos, 17 ms.

testChatGPT.py: 19 findings, 11 aciertos reales, 8 falsos positivos, 6,6 s.

stress_test_zia.py: 13 findings, 9 aciertos reales, 4 falsos positivos, 0,8 s.

codigo_generado.py: 13 findings, 11 aciertos reales, 1 falso positivo, 17 ms.

Lo bueno: el detector capturaba paquetes inventados, typosquatting, APIs inventadas, dunders y decoradores. El recall era alto en los cuatro ficheros.

Lo malo: los falsos positivos se disparaban en algunos. El más descarado: config.url, config.timeout, config.output_file. ¿Por qué? Porque el detector trataba cualquier x.y como si x fuera un paquete de PyPI. Y config era una variable local, no un módulo. Pero como en PyPI existe un paquete llamado config, el detector lo descargaba, no encontraba url ni timeout, y los marcaba como alucinaciones. Cuatro falsos positivos de golpe.

Y otro: pandas.DataFrame._name_. DataFrame existe, pero _name_ es un atributo de la clase, no del módulo pandas. El detector buscaba DataFrame._name_ en el wheel de pandas, no lo encontraba, y lo marcaba. Falso positivo.

Los resultados, con modo profundo

Aquí es donde el modo deep debería lucirse. Y lo hizo… después de arreglar los bugs. Los resultados finales:

test_debe_detectar.py: 15 sin deep, 14 con deep, 0 falsos positivos, 21 ms.

testChatGPT.py: 19 sin deep, 11 con deep, 0 falsos positivos, 35 ms.

stress_test_zia.py: 13 sin deep, 9 con deep, 0 falsos positivos, 16 ms.

codigo_generado.py: 13 sin deep, 12 con deep, 0 falsos positivos, 16 ms.

Cero falsos positivos en los cuatro ficheros. El modo deep eliminó los FPs de atributos reales (pandas.DataFrame, requests.Session, numpy.ndarray) y no introdujo ninguno nuevo. Y con el caché caliente, los análisis bajan a 16–35 ms.

Las cinco insuficiencias (y sus soluciones)

Aquí viene lo importante: lo que estaba mal y cómo lo arreglamos. Cinco bugs, todos encontrados probando con código real.

1. El caché de resultados no se escribía

El problema: el modo deep analizaba el wheel de pandas (11 MB, ~1000 archivos) en cada ejecución, porque el caché de resultados (wheel_results.json) no se estaba escribiendo. El CLI no llamaba a ConfigureWheelResults ni a FlushWheelResults. Resultado: cada análisis tardaba 6-11 minutos.

La solución: llamar a ConfigureWheelResults al arrancar y a FlushWheelResults al salir. De 11 minutos a 31 ms (con caché caliente).

2. config se trataba como paquete de PyPI

El problema: cuatro falsos positivos (config.url, config.timeout, config.output_file ×2). El detector verificaba cualquier x.y como si x fuera un import.

La solución: isAttributeHallucinationDeep ahora recibe la lista de módulos importados. Si el primer segmento (config) no está en los imports, devuelve CANT_VERIFY (no marca). De 4 FPs a 0.

3. El wheel de numpy no se guardaba (o tardaba una eternidad)

El problema: numpy pesa 17 MB. La primera descarga tardaba 4,5 minutos en una conexión lenta. Y a veces el .tmp se quedaba a 0 B (descarga colgada).

La solución: el caché de versión (ver punto 5) evita consultar PyPI en cada análisis. Y el wheel se guarda una vez; la segunda vez es instantáneo. De 4,5 min a 32 ms.

4. numpy.isfinite se marcaba como alucinación (falso positivo)

El problema: np.isfinite existe en numpy. Pero el detector lo marcaba porque isAPIHallucination era heurístico (no usaba el wheel) y isfinite no estaba en la lista de funciones conocidas. En modo deep, solo se verificaban atributos, no llamadas. Por eso isfinite se quedaba con la heurística y daba FP.

La solución: en modo deep, también verificar las llamadas contra el wheel. Y si no se puede verificar (stdlib sin wheel, como json), caer a la heurística (marcar como “posible”). isfinite desaparece, json.serialize_object se mantiene.

5. El caché de versión: 38 segundos por consulta a PyPI

El problema más grave: FetchWheel consultaba PyPI JSON en cada análisis, aunque el wheel estuviera en caché. Y en una conexión lenta, PyPI JSON tardaba 38 segundos. Multiplicado por cada atributo, era inviable.

La solución: un caché de versión (version_cache.json, TTL 1 hora, configurable). Solo consulta PyPI una vez por paquete por hora. De 38 s a 31 ms.

Lo que aprendimos

El código generado por IA es un campo de pruebas excelente. Los tres ficheros (ChatGPT, Z.ai, y el tercero) sacaron a la luz bugs que el corpus propio no detectaba. Probar con código ajeno es obligatorio.

Un detector que se cree más listo de lo que es, es peligroso. Los falsos positivos de config y numpy.isfinite erosionaban la confianza. Mejor un CANT_VERIFY honesto que un error mentiroso.

El caché no es un lujo, es una necesidad. Sin caché de versión y de resultados, el modo deep es inviable en conexiones lentas. Con caché, es instantáneo.

Verificar contra el wheel real funciona… si el wheel está en caché. La primera descarga de numpy (17 MB) es lenta, pero solo una vez. Después, 32 ms.

Sospechoso no es lo mismo que demostrado (y por qué el mensaje dice “posible”)

Hay un matiz que conviene dejar claro, porque es la diferencia entre una herramienta fiable y una que da falsa tranquilidad: detectar código sospechoso no es lo mismo que demostrar que una IA alucinó. Y nuestro detector, después de los fixes, distingue tres estados:

NOT_FOUND: el símbolo no existe en el wheel de PyPI. Es una alucinación (o un error), con evidencia objetiva. Se marca como error.

CANT_VERIFY: no se pudo verificar (stdlib sin wheel, variable local, cadena anidada). No sabemos. Se marca como “posible” (heurística) o no se marca.

CONFIRMED: el símbolo existe. No se marca.

Por eso el mensaje dice “posible alucinación”, no “alucinación”. No prometemos más de lo que damos. Y la detección es del error, no del autor: funciona igual con código de IA que con código humano. Si requests.get_json no existe, no existe, lo escribiera quien lo escribiera.

El reto de la división por cero (y por qué no es nuestro objetivo)

Alguien me planteó este código como reto:

import numpy as np

def calcular_rendimiento(datos):

media = np.mean(datos)

desviacion = np.std(datos)

return media / desviacion

El código es sintácticamente válido, usa APIs reales, pero si la desviación es cero, el resultado es infinito. ¿Debería detectarlo nuestro detector?

No. Y es importante entender por qué. Eso no es una alucinación de IA: es un bug de lógica (división por cero). El código no inventa nada; comete un error condicional. Detectar eso requiere análisis semántico (flujo de datos, condiciones, tipos), que es el terreno de pyright, mypy, pylint o ruff, no el de un detector de alucinaciones de API.

Nuestro objetivo es otro: detectar símbolos inventados (paquetes, funciones, atributos que no existen). Si la IA hubiera escrito np.std_inventado(datos), eso sí lo detectaríamos (la función no existe en el wheel). Pero media / desviacion es código correcto con un bug de lógica. No es nuestro campo. Y no pasa nada por decirlo: una herramienta honesta reconoce sus límites.

Qué dice el estado del arte (y qué herramientas existen)

No somos los primeros en esto. La literatura reciente confirma que las alucinaciones de código son sistemáticas: los LLMs generan código que usa características de librerías inexistentes en 8,1% a 40% de los casos en benchmarks de NL-a-código. Y el análisis estático puede detectar entre 14% y 85% de esas alucinaciones, con un techo estimado del 48,5% al 77% de lo que es plausible detectar estáticamente. No resolvemos el problema al 100%, pero atacamos una parte importante y barata.

Existen varios enfoques y herramientas:

depverify (PyPI) es la herramienta más cercana a lo que hacemos: verifica paquetes y, con --symbols, atributos de paquetes ya instalados localmente. Su ventaja: es determinista (sin LLM), y con --symbols verifica cadenas de atributos con getattr sin ejecutar el código analizado. Sus límites: no descarga wheels (si el paquete no está instalado, CANT_VERIFY), no detecta imports dinámicos (importlib.import_module(“…”)), y la tabla de alias import-nombre es artesanal e incompleta. Nuestro enfoque descarga el wheel de PyPI, lo que nos permite verificar sin instalar nada y sin depender del entorno del servidor. Ellos verifican el entorno; nosotros verificamos PyPI.

Hallucination Inspector (2026) es un juez de fact-checking para migración de APIs de Android: usa un oráculo determinista derivado de la documentación oficial del SDK, y distingue “Phantom Symbols” (símbolos atómicos inventados) de “Phantom Members” (métodos inventados en un objeto). Su arquitectura es estática, ligera y language-agnostic. Es el enfoque más parecido al nuestro, pero para Android y con documentación oficial como oráculo. Nosotros usamos el wheel de PyPI como oráculo.

LettuceDetect (Hugging Face) es un detector generativo (modelo Qwen 2B) que localiza spans alucinados en texto y código, con categorías y subcategorías. Su ventaja: cobertura multi-dominio (código, prosa, 14 idiomas) y localización fina (spans). Su límite: es un modelo probabilístico, no determinista; y está entrenado contra un contexto de referencia (RAG), no contra PyPI. Detecta “esto no está soportado por el contexto”, no “esto no existe en el paquete”. Son enfoques complementarios, no competidores.

CodeHalu (AAAI 2025) propone una taxonomía de alucinaciones de código (mapeo, nombrado, recursos, lógica) y un benchmark de 8.883 muestras para evaluarlas mediante verificación por ejecución. Su fortaleza: cobertura amplia y evaluación sistemática. Su límite: requiere ejecutar el código (o test suites), lo que no es viable en un servidor por seguridad y coste. Nosotros no ejecutamos; leemos el wheel.

En resumen: el estado del arte se divide entre verificación estática determinista (como depverify, Hallucination Inspector, y nuestro detector) y detección probabilística/generativa (LettuceDetect, modelos de incertidumbre). Ninguna es perfecta. Las estáticas son baratas, deterministas y honestas, pero no detectan bugs de lógica ni errores semánticos. Las generativas cubren más, pero pueden alucinar también (un detector que alucina es irónico) y no dan evidencia objetiva. Nuestro detector está en el primer grupo, y con orgullo: preferimos decir “esto no existe en el wheel” (evidencia) que “esto parece raro” (opinión).

Lo que sigue

El detector está sólido: 0 FPs en los cuatro ficheros, 16-35 ms con caché caliente, y verifica de verdad contra PyPI. Pero quedan cosas:

Eventos de descarga de wheel en la barra de progreso (hoy, la primera vez con deep, la barra se congela mientras baja el wheel).

Agrupar hallazgos relacionados (hoy reqeusts sale dos veces: como typosquatting y como package-exists; son el mismo problema).

Detección por versión (verificar contra la versión que usa el proyecto, no solo la última de PyPI).

Falsos negativos estructurales: session.get_json (heurística) y create_parents (método de clase) se escapan. Fase 3.

Precargar wheels populares (numpy, pandas, requests) en el servidor, para que el primer usuario no espere.

El artículo anterior terminaba con una idea: “el linter ve lo que conoce; el type checker ve lo que está en la firma; ninguno sabe si el método existe de verdad”. Este es el intento de cerrar ese hueco de verdad, con verificación contra PyPI, caché y pruebas con código generado por IA. No es perfecto (hay falsos negativos que reconocemos y la primera descarga es lenta), pero es honesto: medimos, documentamos y corregimos. Y los cuatro ficheros de prueba están ahí, con sus resultados, para que cualquiera pueda repetir el experimento.

Si logro subir la web a un servidor, sería genial someterla a pruebas. Puede ser útil.

Materiales reproducibles

Los ficheros .py usados en las pruebas están disponibles en una carpeta pública de Google Drive:

test-files — Materiales reproducibles: https://drive.google.com/drive/folders/12gBRD-Ut7Gae-2_JO6baqG-VepiQvjYZ?usp=drive_link

Incluye:

Corpus propio: test_debe_detectar.py (recall), test_no_debe_detectar.py (precisión), test_limitaciones.py (limitaciones), test_syntax_error.py (sintaxis rota).

Código generado por IA: testChatGPT.py, stress_test_zia.py, codigo_generado.py.

Ficheros mínimos: test_attr_pandas.py, test_attr_requests.py, test_attr_numpy.py, test_config_bug.py, test_pandas_only.py.

Varios: robust_check.py, limpio.py.

README.md con la descripción de cada fichero, los resultados esperados y qué detecta (y qué no) el detector.

Cómo reproducir los resultados: cuando el frontend y el servidor estén desplegados en un servidor público, bastará con subir cada .py desde la web y pulsar “Análisis profundo”. Mientras tanto, los ficheros están ahí para inspeccionarlos, y el README.md describe qué se espera de cada uno. La idea es que, en cuanto el hosting esté listo, cualquiera pueda repetir el experimento sin instalar nada.

Nota: la primera vez con análisis profundo, el servidor descarga los wheels de los paquetes (pandas, numpy, requests…). En una conexión lenta, eso puede tardar minutos. Las siguientes veces son instantáneas (caché).

Referencias

Spracklen, J., Wijewickrama, R., Sakib, A.H.M.N., Maiti, A., & Viswanath, B. (2025). We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs. 34th USENIX Security Symposium. arXiv:2406.10279.

Miranda-Pena, C., Reeson, A., Paris, C., Poon, J., & Kummerfeld, J.K. (2026). An Empirical Analysis of Static Analysis Methods for Detection and Mitigation of Code Library Hallucinations. arXiv:2604.07755.

Tileria, M., et al. (2026). Hallucination Inspector: A Fact-Checking Judge for API Migration. arXiv:2604.20202.

Raj, A., & (coautor). (2026). Names Can Hurt: Spotting Slopsquatting Risks Caused by Package Name Hallucinations in Local Coding LLMs. arXiv:2608.23897.

Churilov, A. (2026). The Range Shrinks, the Threat Remains: Re-evaluating LLM Package Hallucinations on the 2026 Frontier-Model Cohort. arXiv:2605.17062.

Tian, Y., Yan, W., Yang, Q., et al. (2025). CodeHalu: Investigating Code Hallucinations in LLMs via Execution-based Verification. AAAI 2025.

KRLabsOrg. (2026). LettuceDetect-v2-Qwen-2B. Hugging Face.

depverify. (2026). depverify: Deterministic dependency and symbol verification for Python. PyPI.