Aplicado a tus proyectos · Pipeline (TheLedger)
El gate que no deja que un LLM invente números
El Capítulo 1 te dijo que los LLM "alucinan con total confianza" y lo dejó ahí. Este es el problema resuelto en producción de verdad: dos capas de defensa, cada una cerrando el hueco que la otra no puede ver, disparadas por un incidente real con plata real de por medio.
0. El incidente Micron
Tu pipeline genera artículos financieros a partir de filings de la SEC con un LLM. El 2026-07-09, sobre un 10-Q de Micron Technology, ese LLM produjo dos afirmaciones falsas en la misma corrida — y son dos clases distintas de falla, que terminaron necesitando dos mecanismos de defensa distintos:
Fabricación pura
"Las ganancias se vieron infladas por un reembolso de aranceles único de $14.5 mil millones." Un evento que no existe en ningún lado del filing.
Número real, atribución falsa
El flujo de caja operativo del período actual declarado como $11.8 mil millones — cuando la columna del período actual en el filing decía $45.7 mil millones. $11.8B sí aparece en el filing: es el dato del año anterior.
La primera es fácil de imaginar cómo detectar: ese número no está en ningún lado del filing. La segunda es mucho más traicionera — el número sí existe, letra por letra, en el documento fuente. Solo está pegado a la columna equivocada.
1. Capa 1: el gate determinístico
numeric_gate.py no usa ningún LLM. Es puro regex y aritmética: extrae todos los números
del filing crudo en tres "pools" (exactos, escalables por unidad implícita, porcentajes), y después
chequea que cada número que aparece en el artículo generado tenga una contraparte en esos pools.
La primera versión usaba una tolerancia relativa fija (±0.8%) para permitir redondeos normales de prosa ("$15.3 mil millones" describiendo una tabla que dice "15,378"). Ese mismo margen es lo que dejó pasar el fraude de Micron: la tabla real tenía una línea "Additional capital 14,442" (en millones = $14.442B) — nada que ver con un reembolso de aranceles, pero a solo 0.4% de "$14.5 billion", dentro de la tolerancia.
🔧 El fix real: bandas de precisión
La v2 (post-incidente) reemplaza la tolerancia fija por una idea más simple y más estricta: un
número solo "funda" un reclamo si redondea o trunca a él, a la precisión que el
reclamo mismo declara. "$14.5 billion" tiene 3 cifras significativas → su banda es
[14.45B, 14.55B). 14,442 en millones es $14.442B — cae
afuera de esa banda por muy poco. Nunca podría redondear o truncarse a "14.5". La tolerancia
vieja medía distancia; la nueva pregunta es distinta: "¿este número podría legítimamente haberse
escrito así?"
Tres detalles de implementación que valen la pena por lo que enseñan de ingeniería de texto:
- Escalas implícitas: una tabla dice "15,378" con un header "(in millions)" en algún lado; la prosa dice "$15.4 billion". El gate prueba mil/millón/billón como escalas candidatas antes de descartar un número.
- Tokens "seguros" nunca cuentan: años (2026), enteros chicos (conteos, "10-Q"), nunca entran al pool ni se chequean como reclamos — de lo contrario cualquier año mencionado "fundaría" cualquier claim numérico cercano.
- Todo corre sobre texto, nunca sobre JSON parseado a mano: el mismo
_NUMERIC_TOKEN_REse aplica recorriendo cada string del payload — así el gate no necesita saber la forma exacta del JSON que genera el LLM, que cambia con cada versión del prompt.
2. Por qué la capa 1 no alcanza
Un gate por presencia, por más preciso que sea, tiene un techo estructural: solo puede preguntar
"¿este número existe en el filing?" — nunca "¿este número describe lo que el artículo dice
que describe?". $11.8 mil millones pasa el gate de la capa 1 sin problema: es un
número real del filing de Micron. El gate no tiene forma de saber que está en la columna del año
anterior, no en la del actual.
Leer una tabla financiera — entender qué columna es el período actual y cuál es el anterior, qué línea corresponde a qué concepto — es exactamente el tipo de tarea donde un LLM le gana a cualquier regex. El problema es el inverso: ¿cómo confiás en el juicio de un sistema que sabés que puede alucinar, para que sea el que certifique que otro LLM no alucinó?
3. Capa 2: verificar con recibos, no por voto
La solución de claim_verifier.py es no confiar en el veredicto del LLM verificador por sí
solo. Se le muestra cada reclamo numérico junto a fragmentos copiados verbatim del
filing crudo, y se le pide una cita textual exacta que lo respalde — pero el código, no el modelo, es
quien decide si esa cita realmente prueba algo:
- Se buscan en el filing crudo todos los fragmentos que contienen un número compatible con el reclamo (misma lógica de bandas de precisión que la capa 1).
- El LLM recibe el reclamo + esos fragmentos, y debe responder con un veredicto (
supported/unsupported) más una cita exacta. - El código verifica dos cosas sobre esa cita, sin confiar en el modelo: que exista carácter por carácter (normalizando espacios) dentro de los fragmentos que el código mismo recortó, y que contenga un número que efectivamente matchea el reclamo.
🔧 Por qué esto es fail-closed contra un verificador que alucina
Un modelo que "alucina su verificación" — que inventa que vio una cita que nunca existió — no puede pasar: el string-match del código no va a encontrar esa cita inventada dentro de los fragmentos reales. El LLM aporta el juicio (¿esta columna es la correcta?); el código aporta la prueba (¿esa cita existe de verdad?). Ninguno de los dos solo alcanza.
El prompt al verificador es explícito sobre el problema exacto del incidente: "los estados financieros suelen imprimir el período actual en la primera columna numérica y los períodos anteriores a su derecha; un valor que aparece solo en una columna de período anterior no respalda un reclamo sobre el período actual." Esa instrucción no es genérica — está escrita para el error puntual que ya pasó.
4. Fail-closed en cada bifurcación
Todo el sistema comparte un principio: ante cualquier ambigüedad, no publicar es la opción por defecto. Pero hay una distinción fina que vale la pena remarcar, porque es fácil de pasar por alto: no toda falla es un veredicto.
| Situación | Tratamiento |
|---|---|
| El filing crudo no existe o está corrupto | Cuarentena — no se puede verificar, no se publica |
| El verificador responde JSON malformado | Cuarentena — respuesta inutilizable cuenta como "no soportado" |
| El verificador dice "unsupported" | Cuarentena — veredicto negativo real |
| La cita no matchea verbatim en los fragmentos | Cuarentena — el modelo no probó lo que dijo |
| El servidor vLLM del verificador no responde | No es cuarentena — VerifierUnavailableError, reintentar, artefactos intactos |
Esa última fila es la más sutil de todas: un timeout de vLLM no es lo mismo que un modelo diciendo "esto no está probado". Si un hipo de infraestructura borrara y descartara una síntesis válida como si el modelo la hubiera rechazado, terminarías perdiendo trabajo bueno por un problema que no tiene nada que ver con la calidad del contenido. La distinción entre "no pude verificar por una falla técnica" y "verifiqué y no está soportado" está modelada explícitamente en el tipo de excepción que se lanza.
5. El harness que decide si un modelo nuevo puede servir
scripts/model_acceptance.py resuelve una pregunta que conecta directo con
tu pipeline de cuantización: cuando cambiás el modelo o la
cuantización que sirve de verificador, ¿cómo sabés que el nuevo sigue detectando lo que tiene que
detectar? La respuesta acá no es "correr un benchmark genérico y mirar el score" — es volver a correr
el incidente real de Micron contra el candidato.
La barra de aceptación tiene dos requisitos, y el segundo es tan importante como el primero:
- La síntesis de Micron tiene que fallar, y entre las fallas tienen que aparecer las dos firmas exactas del incidente: el "$14.5 billion" fabricado y el "$11.8 billion" mal atribuido. Esto último es lo que tu propio comentario en el script llama "the acid test" — detectar la mala atribución exige que el modelo lea los headers de columna de una tabla, algo que ningún gate determinístico puede hacer.
- Dos casos limpios conocidos (HIMS, VST) tienen que pasar, sin falsos positivos. Un modelo que rechaza todo es, para este propósito, tan inútil como uno que no rechaza nada.
🟢 La idea que generaliza
"Un modelo que no puede reproducir estos resultados no debe gatear producción, sin importar qué diga su porcentaje de recuperación en el benchmark" — tu propio comentario en el script. Es la misma lección que ya viste en la página de cuantización: un log que dice "salió bien" no es lo mismo que un artefacto verificado. Acá el equivalente es: un benchmark genérico que dice "95% de recuperación" no es lo mismo que reproducir el incidente real que te importa.
6. Bugs reales que ya encontraste
El código está lleno de comentarios fechados que documentan hallazgos de revisión adversarial — vale la pena leerlos como casos de estudio de lo difícil que es este problema en la práctica:
"16 basis points" → 16 mil millones
Sin un límite de palabra en el regex, el sufijo de escala ("b" de billion) se comía la primera letra de la palabra siguiente: "16 b[asis points]" se parseaba como "16 B". Un bug de regex, no de IA — pero envenenaba tanto el pool de referencia como los reclamos.
Una fecha como "prueba" de un reclamo en dólares
"2026" multiplicado por la escala implícita de millones cae dentro de la banda de un reclamo de "$2.0 mil millones". Los tokens "seguros" (años, conteos chicos) tuvieron que excluirse explícitamente de poder actuar como respaldo.
Metadata de producción leída como cifra financiera
Un cue de video a los 35 segundos, o la duración de una escena, son metadata de renderizado — no reclamos sobre el filing. 9 de 13 fallas del gate de media en una corrida real (E2) fueron por esto, antes de excluir esas claves.
El título de un artículo citado, leído como cifra del filing
Un artículo externo citado como fuente, titulado "...Up +27.9%...", exponía ese porcentaje como si el modelo lo hubiera narrado. Las claves de citación/atribución (source_map, web_sources) tuvieron que podarse como subárbol completo, sin importar su forma interna.
Ninguno de estos cuatro es un problema de "el LLM alucinó" — son todos formas en las que la infraestructura de verificación misma puede fallar en silencio si no se la revisa con la misma sospecha que al modelo que está vigilando.
7. Resumen
El Capítulo 1 nombra el problema; esto es la respuesta de ingeniería completa:
- Capa 1 (determinística): ¿este número existe en el filing, a la precisión que el reclamo declara? Rápida, sin LLM, cero costo de inferencia.
- Capa 2 (verificación con recibos): ¿este número existente describe lo que el artículo dice que describe? Necesita juicio semántico (un LLM), pero el código exige una prueba verbatim que un modelo que alucina no puede falsificar.
- Fail-closed everywhere, con una distinción cuidadosa entre "no pude verificar" (infraestructura, reintentar) y "verifiqué y está mal" (veredicto real, cuarentena).
- Un harness de aceptación que gatea cualquier cambio de modelo o cuantización contra el incidente real, no contra un benchmark genérico — la disciplina de verificar el artefacto, aplicada a comportamiento en vez de a pesos.
Alucinar con total confianza deja de ser una limitación que solo "tenés que tener en mente" cuando hay una capa de código que no te deja publicar sin la prueba.