Verificación de anomalías técnicas en código Python mediante fuentes verificables

El método que nunca existió

Hace unos meses revisé código que había salido de un asistente de IA. Un forista lo valoró como posiblemente útil y sugirió algunos artículos para la comunidad. Aquí hago un resumen del método empleado y la pequeña historia del intento hasta el momento.

El primer intento pasaba los tests, pasaba el linter, pasaba el type checker. Y sin embargo, en una ruta que los tests no cubrían, había una llamada a requests.get_json(). Ese método no existe en requests. Nunca ha existido. Si esa ruta se hubiera ejecutado en producción, habría fallado con un AttributeError que ningún test habría visto antes.

Ese caso es el punto de partida de esta serie de tres artículos y el desarrollo de un clic para detectar anomalías técnicas en código Python. El primero contó el registro artesanal de APIs y sus techos. En el segundo resumo mi aprendizaje de la teoría: enfoques deterministas frente a enfoques generativos. El tercero habla de la herramienta a prueba con código de IA de verdad y el arreglo de cinco bugs.

Este cuarto artículo responde a una pregunta distinta. Si ya tenemos un detector que discrimina lo que existe de lo que no, ¿dónde exactamente se acaba su alcance? No como disclaimer o descargo de responsabilidad al final, sino como mapa. Porque la distinción entre lo que se refuta con evidencia y lo que no se puede verificar, no es una nota al pie: debería ser la arquitectura del detector.

La frase que resume la serie es del tercer artículo: “preferimos decir ‘esto no existe en el wheel’ (evidencia) que ‘esto parece raro’ (opinión)”. Y hay una corrección que este artículo introduce desde el principio: decir “esto no existe” es afirmar demasiado. Lo que el detector puede afirmar es “esto no se encontró en el ámbito verificado”. La diferencia entre ambas frases es el tema de este artículo y los objetivos siguientes

Conviene aclarar el alcance antes de seguir. Esta herramienta no pretende, no puede en su estado actual y dada la complejidad de este tema, competir inicialmente con todas las plataformas de evaluación de LLM. No aspira a ser un benchmark universal ni un detector de autoría, ni un juez semántico. Se concentra en un objetivo concreto y medible: detectar con la más alta precisión posible anomalías técnicas y alucinaciones de APIs en código Python, proporcionando evidencia reproducible de cada hallazgo. Ese alcance acotado es deliberado: es lo que permite que el detector sea determinista, plausible y explicable, y es lo que hace que sus límites se puedan nombrar con precisión.

Para situar en contexto: registro artesanal, mapa y prueba.

El primer artículo partió de un registro artesanal en Go: un mapa de librería → conjunto de funciones válidas, con una decisión de diseño explícita: “si la librería no está en el registro, no se marca nada”. Eso evitaba falsos positivos masivos, pero también significaba que la cobertura era tan buena como lo fuera el registro. El registro se quedó corto en cuanto aparecieron sub-módulos (os.path.*, requests.adapters, urllib3.util.retry). El caso más instructivo fue el de PyPI: en una red con DNS roto, la regla de paquetes inexistentes se saltaba en silencio, y el detector daba 4 hallazgos donde debía dar 5. La lección: “un detector que se calla cuando no puede verificar da falsa tranquilidad”.

En el segundo artículo hice el intento de explorar el mapa de la teoría. Dos familias: deterministas (que verifican contra una fuente de verdad externa) y las generativas (que infieren la probabilidad de error mediante modelos). Ninguna cubre el problema completo. Una pregunta entonces quedó abierta: “cómo integrarlos sin que la incertidumbre del segundo contamine la fiabilidad del primero”. Y una advertencia vigente: la auto-consistencia detecta alucinaciones inestables, pero falla con las estables. “El modelo es reproduciblemente incorrecto, y la consistencia era la señal.”

El tercer artículo dio el salto al wheel y puso la herramienta a prueba con código de IA. Cinco bugs, todos encontrados probando con código ajeno. Y una distinción que organiza todo lo demás: los estados de verificación.

Esta serie no cuenta una herramienta perfecta. Cuenta techos sucesivos que se fueron alcanzando con el aprendizaje del estado del arte. Este artículo es el mapa de esos techos, y también una corrección: el primero de esos techos era creer que “no encontrado” y “no existente” son lo mismo.

Los estados de verificación: la arquitectura de la evidencia

En el tercer artículo hablábamos de tres estados: NOT_FOUND, CANT_VERIFY, CONFIRMED. Este artículo los refina, porque la formulación importa. No es equivalente afirmar “el símbolo no existe” que afirmar “el símbolo no se encontró en el wheel de requests versión X, bajo el entorno Y”. La segunda es más larga, pero mucho más defendible. Y esa diferencia es la que separa un detector que afirma demasiado de uno que afirma exactamente lo que ha comprobado.

El modelo al que aspira la herramienta debe considerar seis estados, y cada uno incluye el ámbito de verificación: paquete, versión, fuente y entorno.

Estado Interpretación Nivel de alerta
CONFIRMED_IN_TARGET_VERSION Se comprobó que el símbolo existe en el ámbito verificado Sin alerta
CONFIRMED_PRIVATE Se comprobó que existe, pero es API interna (prefijo _ o no exportado) warning: uso de API interna
NOT_FOUND_IN_TARGET_VERSION No se encontró en la versión seleccionada error: posible error de API
REMOVED_API Existió en una versión anterior y fue eliminada error: deriva de versiones
API_VERSION_MISMATCH Existe, pero no en la versión requerida por el proyecto error: error de versión
CANT_VERIFY La comprobación no fue concluyente Sin alerta, o “posible” con aviso

La distinción entre CONFIRMED_PRIVATE y NOT_FOUND_IN_TARGET_VERSION es importante. Un símbolo como _name_ o _internal_method puede existir realmente y ser accesible desde código Python: el prefijo _ es una convención de privacidad, no una barrera técnica. Por eso la herramienta no debe marcar automáticamente una API privada como alucinación. Lo que debe hacer es separar dos problemas distintos: la existencia del símbolo y la calidad o estabilidad de su uso.

Resultado Interpretación Nivel de alerta Estado(s) asociado(s)
Símbolo no encontrado Posible error de API error NOT_FOUND_IN_TARGET_VERSION / REMOVED_API / API_VERSION_MISMATCH
Símbolo encontrado, pero privado Advertencia de uso de API interna warning CONFIRMED_PRIVATE
Símbolo encontrado y público Confirmado Sin alerta CONFIRMED_IN_TARGET_VERSION
Símbolo encontrado, pero uso incompatible Posible error de uso warning CANT_VERIFY (o parcial de Mapeo)

Y nunca hay que confundir “no encontrado” con “inexistente”. Un símbolo podría existir en una versión anterior, existir en una versión futura, estar disponible en un fork, ser parte de un módulo interno, estar definido dinámicamente, ser exportado mediante _getattr_, o no estar presente en el wheel consultado pero sí en el entorno local!. Por eso NOT_FOUND_IN_TARGET_VERSION es la formulación correcta, y NOT_FOUND a secas es la que hay que retirar del mensaje y de la documentación.

Hay un matiz importante: el fail-open (no marcar cuando no se puede verificar) no es debilidad. Es la decisión que preserva la confianza. Pero fail-open con aviso, no silencioso. El silencio del caso PyPI/DNS era el fallo, no el fail-open en sí. Y la detección es del error, no del autor: si un símbolo no existe en el ámbito verificado, no existe, lo escribiera quien lo escribiera.

Este es el modelo al que aspira la herramienta. La versión actual tiene tres estados y está migrando a seis. El artículo documenta el destino, no solo el presente.

La tabla: cuatro niveles cruzados con los estados

La taxonomía de cuatro niveles no es mía, ñor supuesto: es de CodeHalu (AAAI 2025), que distingue nomenclatura, mapeo, recursos y lógica. Lo que añado es aplicarla al caso PyPI y cruzarla con los estados de verificación.

Nivel (CodeHalu) Qué es ¿Lo cubre el detector? Estado típico Ejemplo de la serie
Nomenclatura El símbolo no existe con ese nombre Sí, determinista NOT_FOUND_IN_TARGET_VERSION requests.get_json, json.loads_string, os.read_file
Mapeo El símbolo existe pero se usa mal (módulo vs. respuesta, submódulo, atributo de clase) Parcial: con wheel, sí; con heurística, no NOT_FOUND_IN_TARGET_VERSION o CANT_VERIFY pandas.DataFrame._name_, config.url, os.path.join
Recursos Paquete inexistente o typosquatting Sí, determinista vía PyPI NOT_FOUND_IN_TARGET_VERSION (paquete) fast_json_parser, reqeusts, pandsa, matplotib
Lógica El código existe y compila, pero hace algo incorrecto No (por diseño) Fuera de alcance División por cero; unidad equivocada

La lógica queda fuera por una razón de clase, no de implementación. Aquí conviene ser preciso con el argumento teórico. El teorema de Rice establece que, en general, no existe un algoritmo que decida “todas las propiedades semánticas no triviales de los programas computables”. Esto no impide verificar propiedades concretas bajo restricciones —mediante análisis estático, pruebas, métodos formales o validaciones de dominio—; lo que impide es prometer una solución general y completa para cualquier programa. Por eso la lógica no es un bug pendiente de este detector: es un límite de clase. El wheel responde a una pregunta muy concreta —¿este símbolo pertenece a este paquete, en esta versión?— y no responde a ¿este código hace lo que debe?. La brecha entre “el símbolo existe” y “el código es correcto” es la frontera entre verificación de pertenencia y verificación semántica.

El corpus que no fallaba, y el que sí

El corpus propio no era fácil: era de regresión

El corpus propio, (varios .py en carpetas que nombran sus fuentes generadoras del código a revisar, es decir los cuatro niveles, más los ficheros de ChatGPT) no es un corpus complaciente. Es un corpus de regresión: cada caso que contiene fue, en algún momento, un fallo real que la herramienta no detectaba y que se fue corrigendo. Por eso el recall es 100%: el bucle ya está cerrado sobre cada caso. No es que sea fácil; es que mide y verifica que lo ya arreglado y detectado no se rompa.

El corpus de opencode fue otra cosa: es un corpus de descubrimiento. Se generó sin conocer del todo los límites del detector, y por eso expone combinaciones que el corpus propio no había explorado. Los dos miden cosas distintas, y ambos son necesarios.

Los datos de la validación (modo normal)

La corrida de validación del corpus completo, en modo normal, (es decir, no -deep) que en algún momento anterior fue de una precisión del 100%, sin embargo:

Métrica Valor
Esperados 44
Encontrados 44
Faltantes 0
Extra (no esperados) 7
Recall 100.0%
Precisión 86.3%

En opencode concretamente: 19/19 esperados, 7 extra. Y los 7 extra no son sorpresas: están documentados de antemano en el YAML de expectativas.

Línea Tipo Qué es ¿Documentado en el YAML?
101 api-hallucination df.drop_duplicates_by_columns(...) (anotación válida mal interpretada) Sí
154 api-hallucination json.dumps(..., pretty=True) (argumento inventado sobre función real) Sí
159 api-hallucination subprocess.run(..., check_output=True) (argumento inventado) Sí
201 api-hallucination df.append(...) (API eliminada sobre instancia) Sí
209 api-hallucination np.float(...) (anotación válida mal interpretada) Sí
241 api-hallucination except requests.RequestException (excepción de librería) Sí
74 dunder-hallucination __post_init__ (dunder válido en dataclass) Sí

La precisión del 86.3% es real, pero la causa no es “el detector falla en 7 sitios nuevos”. Es “el detector tiene 7 falsos positivos conocidos, y el corpus de opencode los expone todos”. Un FP documentado es un límite medido; un FP sorpresa sería un fallo.

El dato en modo profundo

Ya con la misma inspección en modo --deep da 19 hallazgos: 18 aciertos reales y 1 solo falso positivo (_post_init_). Los otros 6 FPs del modo normal desaparecen porque el wheel resuelve correctamente la anotación, el atributo o la excepción.

Métrica Modo normal Modo deep
Hallazgos en opencode 19 + 7 extra 19
Aciertos reales 19 18
Falsos positivos 7 1 (__post_init__)
Falsos negativos 0 (en opencode) 1 (np.float, línea 166)
Precisión en opencode 73.1% (19/26) 94.7% (18/19)
Tiempo (caché caliente) ~17 ms ~23 ms

El deep sube la precisión de 73.1% a 94.7% en opencode, a costa de 1 falso negativo: np.float, que el modo normal detectaba y el deep pierde. El deep no es estrictamente mejor; es distinto. Mejora precisión a costa de recall en casos de deriva de versiones. Con el modelo de seis estados, ese caso dejaría de ser un falso negativo: sería REMOVED_API (existió en numpy < 1.24 y fue eliminada), que es información más útil que un simple “no encontrado”.

Y un detalle de tiempo: la primera verificación de un paquete desconocido es lenta (matplotib tardó 21.8 s en descargarse de PyPI), pero con caché caliente el análisis baja a 23 ms.

La lección

El falso positivo que sobrevive en deep (_post_init_) es de contexto, no de pertenencia. El detector sabe que el símbolo existe; lo que no sabe es que el contexto de @dataclass lo valida.

Hay una segunda lección: las limitaciones declaradas también envejecen. Tres casos que el fixture marcaba como [L] (“probablemente no se detecte”) —logging.get_logger, datetime.parse_iso y os.path.validate_path— el detector los detecta hoy. Lo que era limitación ayer es acierto hoy. Por eso el corpus propio es de regresión y no de descubrimiento.

Lo que el wheel no puede decirte (y por qué eso no es una derrota)

El wheel responde a una pregunta muy concreta: ¿este símbolo pertenece a este paquete, en esta versión? No responde a ¿este código hace lo que debe?. La brecha entre “el símbolo existe” y “el código es correcto” no es un fallo de implementación; es la frontera entre verificación de pertenencia y verificación semántica.

Y aquí está la tesis: la distinción entre refutar con evidencia y no poder verificar no es un disclaimer, es la arquitectura. Los seis estados son su forma operativa; la tabla de cuatro niveles es su forma general. Un detector que no distingue estos estados no es más potente: es más ruidoso. Y un detector ruidoso erosiona la confianza más rápido que un detector que reconoce sus límites.

Limitaciones todavía abiertas

  1. No existe una fuente universal de verdad. El wheel de PyPI es un oráculo para paquetes y símbolos, pero no para el comportamiento.
  2. Las APIs cambian entre versiones. Un símbolo puede existir en la versión que usa el proyecto y no en la última de PyPI, o al revés.
  3. Los detectores tienen problemas para generalizar. Lo que funciona en Python no se traslada sin más a JavaScript o TypeScript.
  4. Los falsos positivos pueden deteriorar la confianza del usuario. 7 FPs conocidos bastan para bajar la precisión al 86.3%.
  5. Las alucinaciones semánticas son difíciles de detectar. Requieren análisis de flujo de datos y de condiciones.
  6. Un LLM puede generar afirmaciones falsas pero consistentes. La auto-consistencia no es señal suficiente.
  7. Los detectores de autoría pueden ser evadidos. La detección del error es más robusta que la del autor.
  8. Las métricas y benchmarks no son completamente comparables entre sí. Un 94.7% en un corpus no es comparable con un 94.7% en otro.

Un estudio reciente sobre TRIVIA+ señala que todavía hay espacio para mejorar la evaluación de detectores en RAG, especialmente en contextos largos y con etiquetas ruidosas, y que el ruido de etiquetado afecta negativamente al rendimiento (Amazon Science). El problema de la evaluación no es solo de esta herramienta; es del campo.

Con qué más podemos enriquecer la herramienta

Las oportunidades más importantes

El mayor vacío no es crear otro sistema que consulte un LLM y pregunte “¿este código está bien?”. Las oportunidades están en la integración de métodos y en la detección de errores que escapan a los sistemas convencionales. Un detector de APIs consciente del contexto y de las versiones debería analizar:

  • El nombre exacto del paquete.
  • Su versión instalada.
  • Las versiones compatibles declaradas en requirements.txt, pyproject.toml o el entorno.
  • Los símbolos públicos disponibles.
  • Los argumentos y sus tipos.
  • La documentación de la versión correspondiente.
  • Los cambios de API entre versiones.

Y debería diferenciar entre cinco cosas que hoy tiende a agrupar:

Categoría Ejemplo Método de evaluación ¿Cubierto hoy?
Paquetes inexistentes import smart_csv_loader PyPI :white_check_mark: Sí
APIs inventadas np.array_from_sequence() Verificación de símbolos (wheel) :white_check_mark: Sí
APIs reales mal utilizadas Argumentos inválidos Análisis estático :warning: Parcial
Errores semánticos Fórmula de pendiente incorrecta Especificaciones y tests :cross_mark: No (por diseño)
Errores de lógica científica Volumen físicamente imposible Validación de dominio :cross_mark: No (por diseño)

Esa distinción reduciría significativamente los falsos positivos, porque separa lo que el detector refuta con evidencia de lo que no puede verificar y de lo que verifica pero no puede juzgar en uso. Esta tabla clasifica el tipo de relación entre el símbolo y el paquete; la tabla de cuatro niveles clasifica el tipo de alucinación. Se cruzan.

El sistema de evidencia trazable

Hay un estándar que separa un programa que imprime advertencias de una herramienta de auditoría: la trazabilidad de cada hallazgo. Cada hallazgo debería responder a ocho preguntas:

Pregunta Estado Nota
¿Qué se detectó? :white_check_mark:
¿En qué línea? :white_check_mark:
¿Qué regla lo detectó? :white_check_mark:
¿Qué fuente respalda la sospecha? :warning: En deep sí; en normal no siempre se explicita
¿Cuál es el nivel de confianza? :warning: error/warning, sin campo confidence
¿Qué alternativa correcta se propone? :cross_mark:
¿Qué versión de la dependencia se verificó? :cross_mark: Verifica contra la última de PyPI, no contra la del proyecto
¿Cuál es el ámbito de verificación? :cross_mark: Nuevo: el estado no incluye scope explícito

Tres de ocho se cumplen plenamente; dos, parcialmente; tres, no. Y las que faltan son las que convertirían a un detector en una herramienta de auditoría. Un hallazgo bien formado debería serializarse así:

{
  "symbol": "requests.get_json",
  "line": 95,
  "rule_id": "api-hallucination",
  "status": "NOT_FOUND_IN_TARGET_VERSION",
  "scope": {
    "package": "requests",
    "version": "2.31.0",
    "source": "pypi_wheel",
    "python": "3.11",
    "environment": "project_lock"
  },
  "confidence": "high",
  "evidence": "símbolo no encontrado en el wheel de requests 2.31.0",
  "suggestion": "response.json()"
}

Medir la eficacia con rigor: el corpus de evaluación que falta

El sistema ya tiene reglas configurables y un tiempo de ejecución muy bajo (16–35 ms con caché caliente). El siguiente paso no es añadir más reglas, sino medir su eficacia de forma rigurosa.

Categoría Ejemplo Método de evaluación ¿Cubierto hoy?
Paquetes inexistentes import smart_csv_loader PyPI :white_check_mark: Sí
APIs inventadas np.array_from_sequence() Verificación de símbolos (wheel) :white_check_mark: Sí
APIs reales mal utilizadas Argumentos inválidos Análisis estático :warning: Parcial
Errores semánticos Fórmula de pendiente incorrecta Especificaciones y tests :cross_mark: No (por diseño)
Errores de lógica científica Volumen físicamente imposible Validación de dominio :cross_mark: No (por diseño)

Las dos últimas no cuentan como fallo de la herramienta si su objetivo sigue siendo detectar anomalías técnicas. Pero incluirlas ayuda a demostrar que el detector no está diseñado para detectar cualquier clase de error. Un corpus que solo contiene lo que el detector puede detectar mide su potencia; uno que también contiene lo que no puede detectar mide su honestidad.

Y hay una lección metodológica: las métricas no son completamente comparables entre sí (limitación 8). Un corpus con cinco categorías explícitas, cada una con su método de evaluación, es un paso hacia la comparabilidad.

Mejoras concretas

Derivadas de los falsos positivos:

  • Distinguir anotaciones de llamadas. Los 4 FPs de pd.DataFrame en modo normal vienen de tratar una anotación como una llamada.
  • Reconocer excepciones de librerías conocidas. requests.RequestException es real; el deep ya lo resuelve.
  • Contexto de dataclasses para dunders. _post_init_ es válido en @dataclass.
  • Corregir el falso negativo del deep para np.float. Con REMOVED_API dejaría de ser un FN.

De infraestructura y experiencia:

  • Eventos de descarga de wheel en la barra de progreso.
  • Agrupar hallazgos relacionados. reqeusts sale dos veces; es el mismo problema.
  • Detección por versión. Verificar contra la versión del proyecto.
  • Precargar wheels populares.
  • Caché offline y aviso visible cuando no se pudo verificar.

De cobertura:

  • Falsos negativos estructurales: session.get_json, create_parents. Fase 3.
  • Ampliar el registro a pandas y numpy en modo heurístico.
  • JavaScript/TypeScript como segundo adaptador.
  • Integración con detectores generativos sin contaminar la fiabilidad del determinista.

Nota sobre el servidor: las pruebas que faltan

Todavía no hay un servidor público donde cualquiera pueda subir su código y probar. Cuando lo haya, las pruebas con código real de terceros podrían afinar mucho más la capacidad de detección. No solo porque expondrían FPs que el corpus actual no contiene, sino porque permitirían medir la precisión sobre código que no fue escrito pensando en el detector.

El corpus propio mide regresión; el corpus de opencode mide descubrimiento sobre un agente concreto. Un servidor público mediría descubrimiento sobre el código de cualquiera, que es el único test que importa para una herramienta que aspira a ser útil fuera de su propio repositorio.

Y conviene repetirlo en el cierre: este proyecto no intenta competir inicialmente con todas las plataformas de evaluación de LLM. Su objetivo es más estrecho y más verificable: anomalías técnicas y alucinaciones de APIs en Python, con evidencia reproducible. Si el servidor público llega, servirá para afinar ese objetivo, no para ampliarlo a todo.

Materiales reproducibles

Los ficheros de prueba están en una carpeta pública de Drive: https://drive.google.com/drive/folders/12gBRD-Ut7Gae-2_JO6baqG-VepiQvjYZ?usp=drive_link. Dentro encontrarás:

  • Los cuatro corpus de regresión (level1–level4), con sus .py y su YAML de expectativas.
  • El corpus de ChatGPT (chatGPT/), con los 13 ficheros H001–H010 y C001–C003.
  • El corpus de opencode (opencode/), con test_alucinaciones_naturales.py y su YAML, que incluye los 7 falsos positivos conocidos documentados de antemano.
  • Un README.md con la estructura, los resultados esperados y cómo reproducir la validación.

La idea es que cualquiera pueda analizar y comprobar los números. Si encuentras un FP que no está en el YAML, o un FN que sí está, me interesa saberlo.