La semana pasada mi IA escribió código que leía un fichero JSON del disco, lo parseaba, hacía un lookup, y repetía eso 900 veces dentro de un for. Cada iteración: abrir fichero, decodificar JSON, buscar un valor, descartarlo todo. Vuelta a empezar.
Es un error que enseño a mis alumnos a no cometer al mes de empezar a programar.
Lo que pasó (sin marear la perdiz)
Estoy construyendo Tokamak, una app de menú bar para macOS que monitoriza cuota de Claude Max. Parte de la funcionalidad escanea ~900 ficheros JSONL de sesiones de Claude Code. Para cada fichero, necesita saber el byte offset donde lo dejó la última vez (lectura incremental — solo leer lo nuevo).
Los offsets se guardan en un JSON:
{
"version": 1,
"offsets": {
"proyecto-a/sesion-1.jsonl": 48231,
"proyecto-b/sesion-2.jsonl": 12044
}
}
Un Dictionary<String, UInt64>. 900 entradas. ~55KB. Nada del otro mundo.
Y aquí viene el detalle que lo hace aún más absurdo: ese fichero lo creó la propia app. No es un JSON de una API externa. No viene de Claude Code. Es un fichero de estado interno que Tokamak escribe y lee para saber dónde dejó la lectura de cada sesión. La IA estaba leyendo del disco 900 veces un fichero que ella misma había generado.
«¿Y por qué no usas Core Data o SQLite, que ya los tienes en la app?» Buena pregunta. Porque ese fichero es un caché de progreso desechable. Si se corrompe, lo borras y el siguiente scan reconstruye todos los offsets leyendo los ficheros enteros una vez. Cero pérdida de datos. Además: puedo hacer cat session-offsets.json | jq . para debuggear (con Core Data necesito sqlite3 y el path del sandbox), es Sendable sin la danza de background contexts, y si el SQLite de Core Data se corrompe no arrastra a los offsets (ni viceversa). Para 55KB de un diccionario plano, la ceremonia de una entidad con migración de schema no se justifica.
El formato no era el problema. El acceso sí.
El código que escribió la IA para el bucle de scan:
for file in files { // 900 ficheros
let storedOffset = offsetStore.offset(for: file.relativePath)
// ↑ ESTO lee el JSON del disco y lo parsea. Cada. Vez.
if file.fileSize == storedOffset { continue }
// ... leer fichero, actualizar offset ...
offsetStore.setOffset(newOffset, for: file.relativePath)
// ↑ Y ESTO lo lee OTRA VEZ, modifica, y lo guarda.
}
Dos llamadas al disco por iteración. 900 iteraciones. 1.800 operaciones de I/O donde debería haber exactamente 2: una lectura al principio, una escritura al final.
Los números (xctrace no miente)
Lo pillé con Instruments (Time Profiler). Los datos:
| Métrica | Antes | Después |
|---|---|---|
| Samples totales | 7.260 | 489 |
Samples en OffsetStore.load() |
1.704 (88%) | 10 (2%) |
| Tiempo de scan | >20s | <0.5s |
| CPU | 81% | ~1.5% |
El 88% del tiempo de scan era leer y parsear un JSON de 900 líneas. Una y otra vez. Como Sísifo empujando la piedra, pero con JSONDecoder.
El fix (que debería darte vergüenza ajena)
// ANTES: I/O en cada iteración
for file in files {
let offset = offsetStore.offset(for: file.relativePath) // lee JSON
// ...
offsetStore.setOffset(newOffset, for: file.relativePath) // lee + escribe JSON
}
// DESPUÉS: load once, operate in memory, save once
var offsets = offsetStore.load() // UNA vez
for file in files {
let offset = offsets.offsets[file.relativePath] ?? 0 // O(1) en memoria
// ...
offsets.offsets[file.relativePath] = newOffset
}
offsetStore.save(offsets) // UNA vez
Ojo al dato: la estructura de datos no cambió. Seguía siendo un Dictionary<String, UInt64>. El hash table ya era óptimo. Lo que era subóptimo era reconstruirlo desde disco en cada iteración.
Lo que no funciona: poner «no hagas esto» en tu CLAUDE.md
Después del fix, añadí esto al CLAUDE.md del proyecto:
«NUNCA hacer I/O (disco, red, decode JSON, Core Data fetch) dentro de un bucle si se puede hacer antes. Cargar datos una vez antes del bucle, operar en memoria, guardar una vez después.»
Y aquí viene lo que de verdad quiero contarte: no sirvió de nada.
Semanas después, al añadir un segundo servicio (Codex), la IA generó exactamente el mismo patrón. Con la instrucción delante. Es como poner un cartel de «no pisar el césped» y esperar que funcione.
¿Por qué? Porque el LLM no entiende la regla. La ha visto. Estadísticamente, la mayoría del código que ha leído durante el entrenamiento hace I/O puntual, no en bucles de 900 iteraciones. El patrón load → use → save en una función es lo más probable. Que esa función se llame dentro de un for de 900 iteraciones es un detalle de contexto que el modelo no tiene incentivos para rastrear.
Lo que tampoco funciona: linters
No existe un linter que detecte esto. Ni SwiftLint, ni ESLint, ni Ruff, ni Clippy. Piénsalo: el código es sintácticamente correcto y semánticamente válido. Cada llamada individual a offsetStore.offset(for:) es perfectamente razonable. El problema no está en ninguna línea — está en la composición.
Si miramos las capas de significado del código (una idea que uso en mi curso de desarrollo adversarial):
| Capa | Pregunta | ¿Falla aquí? |
|---|---|---|
| 1. Señal | ¿Esto es código? | No |
| 2. Lenguaje | ¿Es Swift válido? | No |
| 3. Sintaxis | ¿Compila? | No |
| 4. Semántica local | ¿La función hace lo que promete? | No |
| 5. Semántica de sistema | ¿Respeta contratos y rendimiento? | Sí |
| 6. Arquitectura | ¿Escala sin degradarse? | Sí |
El fallo está en capas 5-6. Exactamente donde los LLMs fallan hoy en 2026. La sintaxis y la lógica local son impecables. El problema es emergente: aparece cuando una función correcta se usa en un contexto que la convierte en un cuello de botella.
Un linter opera en capas 2-4. No tiene visibilidad sobre composición ni rendimiento. Es como pedirle al corrector ortográfico de Word que detecte un argumento falaz.
Lo único que funciona: tests de rendimiento a posteriori
Después del primer fix, escribí este test:
@Test("Scan rendimiento no degrada con número de ficheros")
func scanPerformanceDoesNotDegradeWithFileCount() async throws {
// Crear 1000 ficheros JSONL con contenido mínimo
for i in 0..<1000 {
let content = "..." // una línea válida
try content.write(to: dir.appendingPathComponent("session-\(i).jsonl"), ...)
}
// Pre-poblar offset store (simular re-scan)
var offsets = SessionOffsetStore.OffsetData()
for i in 0..<1000 {
offsets.offsets["session-\(i).jsonl"] = 100
}
offsetStore.save(offsets)
let start = ContinuousClock.now
await service.scan()
let elapsed = ContinuousClock.now - start
#expect(elapsed < .seconds(3)) // <3s para 1000 ficheros
}
Es un test de regresión brutalmente simple. 1000 ficheros, menos de 3 segundos, o el test falla. Si alguien (humano o IA) vuelve a meter I/O dentro del bucle, el test pasa de tardar 0.2 segundos a tardar 30, y explota.
Y esto es exactamente lo que pasó. Cuando la IA generó el segundo servicio con el mismo bug, el test de rendimiento del primero seguía pasando (era un servicio diferente). Pero al escribir el test equivalente para el nuevo servicio, falló inmediatamente. El test hizo su trabajo: detectar la regresión que ni el CLAUDE.md ni ningún linter podían ver.
Lo que esto confirma
Este bug es la demostración perfecta de la tesis central de lo que llamo desarrollo adversarial: never trust, always verify.
No puedes confiar en que la IA no cometerá errores de primero de carrera. Los cometerá. Repetidamente. Aunque le digas que no lo haga.
No puedes confiar en que los linters lo detectarán. No pueden. El error está por encima de su nivel de abstracción.
Lo que sí puedes hacer:
- Tests de rendimiento como red de seguridad a posteriori
- Profiling real (xctrace, Instruments) para medir, no adivinar
- Defensa en profundidad: múltiples capas, porque ninguna capa individual cubre todo
Dicho en cristiano: la defensa no es un muro. Es una cebolla. Capas y capas. Y cuando una falla, la siguiente la atrapa.
Para los escépticos
«Pero Fernando, ¿un programador humano no cometería el mismo error?»
Un junior, sí. Un senior, probablemente no — porque tiene el patrón interiorizado. Pero incluso un senior haría code review y lo pillaría. El problema con el código generado por IA es el volumen: 50 ficheros en 10 minutos. Nadie revisa 50 ficheros línea a línea. La fatiga del discriminador es real.
Y por eso necesitas que la verificación sea automática, no humana. El test de rendimiento no se cansa. No se distrae. No tiene fatiga. Corre cada vez que haces make test y te dice si algo huele mal.
Es el mismo principio que aplico en las 5 defensas contra alucinaciones: el sistema de verificación debe ser externo al generador. Si la IA escribe el código, la verificación tiene que venir de otro sitio. En este caso, de un reloj que mide cuánto tarda.
Read this article in English.



