Módulo 5: Shrinking y el ejemplo mínimo
2. Qué es el shrinking y por qué importa
Descripción
En la lección-mapa viste el antes y el después del encogido: un contraejemplo grande (paid=107100) que Hypothesis reduce a uno chico (paid=50). Fue una foto. Esta lección abre el concepto: qué es exactamente el shrinking, qué significa "el caso más pequeño que aún falla", por qué esa reducción apunta directamente a la causa del bug, y —lo más sorprendente— cómo la herramienta lo consigue sin saber una sola línea de tu código.
Al terminar vas a poder definir el shrinking en una frase precisa, no vaga; explicar qué quiere decir "mínimo" en este contexto (no es el más pequeño imaginable, sino el más pequeño que sigue rompiendo la propiedad); describir, a grandes rasgos, el algoritmo que lo logra —probar versiones más simples de la entrada y quedarse con las que aún fallan—; y leer la sección de estadísticas de Hypothesis para ver, en números, cuántos intentos de encogido hizo y cuántos tuvieron éxito. Vas a entender la idea de arriba abajo antes de verla correr sobre un bug de verdad en la próxima lección.
Conexión con el módulo: esta es la lección conceptual del encogido. La lección 1 te dio la motivación y el mapa; esta define el concepto con rigor. La lección 3 lo pondrá a correr sobre un bug de Reservo para que lo veas encoger paso a paso, y la 4 te enseñará a leer el caso mínimo que produce. Aquí todavía no depuramos nada: entendemos qué es el encogido y por qué funciona. Es el puente entre "sé que Hypothesis reduce el contraejemplo" (lección 1) y "veo cómo lo reduce y qué me entrega" (lecciones 3 y 4). Se apoya, además, en algo que ya hiciste con las manos: el encogido a mano del mini-proyecto del módulo 1, que aquí reconocerás automatizado.
Una analogía: quitar ingredientes hasta dar con el que amarga
Imagina que cocinaste un guiso y salió amargo. Tienes la olla entera —veinte ingredientes— y sabes que algo amarga, pero no cuál. Podrías analizar los veinte en un laboratorio, o podrías hacer lo que hace cualquier cocinero: reducir. Preparas el guiso otra vez sin la cebolla: sigue amargo. Fuera la cebolla, no era ella. Ahora sin el laurel: sigue amargo. Fuera el laurel. Sin la sal: sigue amargo. Sin el tomate: sigue amargo. Y así, uno por uno, vas quitando lo que puedes quitar sin que el amargor desaparezca. Llega un momento en que quitas un ingrediente —digamos, esa cáscara de naranja que echaste de más— y el amargor se va. Ese ingrediente es la causa. El guiso reducido a lo mínimo que aún amarga te lo señaló.
El shrinking es exactamente esa cocina de eliminación, aplicada a las entradas de tu test. El "guiso" es el contraejemplo grande: paid=107100, hours_before=64. El "amargor" es el fallo de la propiedad. Hypothesis prueba una versión con el paid más chico —¿sigue fallando?—, y si sí, se queda con ella y sigue reduciendo; si no, la descarta y prueba otra reducción. Baja el paid, baja las horas, siempre preguntándose lo mismo: "¿esta versión más simple sigue amarga —sigue fallando—?". Cuando ya no puede simplificar nada más sin que el fallo desaparezca, se detiene. Lo que queda es el guiso mínimo que aún amarga: el caso mínimo que aún falla.
La analogía deja clara una cosa que confunde al principio: el objetivo no es el guiso más pequeño posible, sino el más pequeño que todavía amarga. Un guiso vacío no amarga —pero tampoco te dice nada—. El cocinero no busca la olla vacía; busca la olla mínima que conserva el problema. Igual, Hypothesis no busca paid=0 (que quizás ya no falla); busca el paid más chico que conserva el fallo. Esa distinción —mínimo entre los que fallan, no mínimo a secas— es el corazón de la definición.
La definición precisa
Con la analogía en la cabeza, aquí está la definición sin metáforas:
El shrinking (encogido) es el proceso por el cual Hypothesis, tras encontrar una entrada que hace fallar tu propiedad, busca una entrada más simple que también la hace fallar, y repite hasta no poder simplificar más. El resultado es el caso mínimo: el contraejemplo más simple que Hypothesis pudo encontrar que sigue rompiendo la propiedad. Es el que te reporta.
Hay tres palabras en esa definición que conviene desmenuzar, porque cada una carga un matiz.
"Más simple". ¿Qué significa que una entrada sea más simple que otra? Para Hypothesis, más simple es, a grandes rasgos: números más cercanos a cero, listas más cortas, cadenas más breves, colecciones más vacías. Un paid=50 es más simple que paid=107100; una lista de dos elementos es más simple que una de cuarenta; la cadena "" es más simple que "x8#kq!". No es una noción arbitraria: está pensada para que el caso mínimo sea legible para un humano. Los humanos entendemos mejor los números chicos y las estructuras pequeñas, así que "simple" se define como "fácil de leer y razonar".
"También la hace fallar". Esta es la condición que no se puede violar. Cada reducción que Hypothesis prueba tiene que conservar el fallo. Si reducir paid de 50 a 49 hace que la propiedad vuelva a pasar, entonces 49 no sirve: el bug vive en 50 o más, y 50 se queda. El encogido nunca te entrega un caso que pasa; por definición, el caso mínimo falla. Es la garantía que hace útil el resultado: no es "un caso pequeño cualquiera", es "un caso pequeño que reproduce tu bug".
"El más simple que Hypothesis pudo encontrar". Un matiz de honestidad. El encogido no garantiza el mínimo absoluto del universo —encontrar eso sería, en general, imposible—; garantiza un mínimo local muy bueno, el más simple que su búsqueda alcanzó. En la práctica es casi siempre tan pequeño y tan claro como el mínimo teórico, pero conviene saber que es "el mejor que encontró", no "el mejor que existe". Volveremos sobre este matiz en la lección 6, donde verás que dos corridas frescas pueden aterrizar en mínimos ligeramente distintos.
Cómo lo logra sin conocer tu código
Aquí viene lo que más sorprende. Hypothesis encoge el contraejemplo sin saber nada de lo que hace refund_cents por dentro. No lee tu implementación, no analiza la aritmética del bono, no entiende el concepto de "reembolso". ¿Cómo puede reducir el caso a lo justo, entonces?
La respuesta es que trata tu propiedad como una caja negra que responde sí o no: dada una entrada, ¿falla o no falla? Eso es todo lo que necesita. El algoritmo, en esencia, es este bucle:
- Tengo un caso que falla (el primero que tropezó, digamos
paid=107100, hours_before=64). - Propongo una versión más simple (por ejemplo,
paida la mitad). - La corro. ¿Sigue fallando? Si sí, me quedo con la versión simple y vuelvo al paso 2 desde ahí. Si no, la descarto y pruebo otra simplificación.
- Repito hasta que ninguna simplificación conserve el fallo.
Fíjate en lo que no hay en ese bucle: no hay ningún análisis de tu código. Hypothesis no "razona" sobre por qué falla; simplemente prueba candidatos más simples y observa el resultado. Es exactamente lo que hace el cocinero: no analiza la química del amargor, solo cocina versiones con menos ingredientes y prueba cuál sigue amarga. Esa ignorancia deliberada es una fortaleza: como no depende de entender tu código, el mismo mecanismo de encogido sirve para cualquier función —un reembolso, un parser, un algoritmo de grafos— sin cambiar una línea. Encoge números, listas, cadenas, fechas y objetos compuestos con la misma estrategia de "propón más simple, comprueba si aún falla".
Esto también explica por qué el encogido cuesta tiempo: para reducir, Hypothesis tiene que volver a correr tu propiedad muchas veces, una por cada candidato que prueba. Si tu test es rápido (aritmética pura, como Reservo), el encogido es instantáneo. Si es lento, el encogido también lo será —una de las razones por las que en la lección 7 aprenderás a ajustar el deadline y las fases—.
Ejemplo trabajado: ver el encogido en las estadísticas
No vamos a depurar todavía —eso es la próxima lección—, pero sí a cuantificar el encogido. Hypothesis lleva la cuenta de cuántos casos generó, cuántos fallaron y cuántos intentos de encogido hizo, y te lo muestra si se lo pides con --hypothesis-show-statistics. Verlo en números convierte "encoge el caso" de una idea abstracta a un proceso medible.
Este es el mismo bug del bono de lealtad sin tope de la lección 1. El test:
# test_shrink_stats.py — cuantificar el encogido
from datetime import datetime, timedelta
from hypothesis import given, settings, strategies as st
from reservo import Booking
from refunds_buggy import refund_cents # bono de lealtad SIN tope
BOOKING = Booking(
id="bk-1", room_id="r-focus", member_id="m-1",
start=datetime(2026, 6, 1, 10, 0), end=datetime(2026, 6, 1, 13, 0),
status="confirmed", price_cents=6000,
)
def refund_at(hours_before, paid):
now = BOOKING.start - timedelta(hours=hours_before)
return refund_cents(BOOKING, paid, now)
@settings(derandomize=True) # fija la semilla: los mismos números en tu máquina
@given(
hours_before=st.integers(min_value=0, max_value=10_000),
paid=st.integers(min_value=0, max_value=1_000_000),
)
def test_refund_in_range(hours_before, paid):
assert 0 <= refund_at(hours_before, paid) <= paid
El @settings(derandomize=True) fija la semilla aleatoria para que obtengas exactamente los mismos números que yo —lo veremos a fondo en la lección 7; por ahora tómalo como "hazlo reproducible"—. Corre el test pidiendo estadísticas:
python3 -m pytest test_shrink_stats.py --hypothesis-show-statistics
Qué esperar. El test falla (el bug rompe el rango), y la sección de estadísticas te muestra dos fases —una de generación y una de encogido— con sus conteos:
============================ Hypothesis Statistics =============================
test_shrink_stats.py::test_refund_in_range:
- during generate phase (0.06 seconds):
- Typical runtimes: ~ 0-27 ms, of which < 1ms in data generation
- 2 passing, 8 failing, and 0 invalid test cases
- Found 1 distinct error in this phase
- during shrink phase (0.06 seconds):
- Typical runtimes: ~ 0-4 ms, of which < 1ms in data generation
- 23 passing, 8 failing, and 0 invalid test cases
- Tried 31 shrinks of which 8 were successful
- Stopped because nothing left to do
Léela como la historia del encogido en dos actos. El primer acto, generate phase, es la búsqueda: Hypothesis generó casos aleatorios, y de ellos 2 passing, 8 failing —dos cumplieron el invariante, ocho lo rompieron— hasta que dio con un fallo (Found 1 distinct error in this phase). Ese es el momento "el guiso salió amargo": ya sabe que hay un bug. El segundo acto, shrink phase, es la reducción: Tried 31 shrinks of which 8 were successful. Tradúcelo con la analogía de la cocina: Hypothesis probó 31 versiones más simples del contraejemplo, y 8 de ellas seguían fallando (seguían amargas), así que se quedó con cada una y siguió reduciendo desde ahí; las otras 23 (23 passing) ya no fallaban —reducciones que "quitaron el amargor", descartadas—. Ocho reducciones exitosas, una tras otra, llevaron el caso de paid=107100 a paid=50. La última línea, Stopped because nothing left to do, es el "ya no puedo quitar más ingredientes sin que deje de amargar": el encogido terminó porque ninguna simplificación más conservaba el fallo.
Esos números son el encogido hecho visible. No es magia ni una sola operación: son 31 preguntas de "¿esta versión más simple sigue fallando?", de las cuales 8 avanzaron hacia el mínimo. El resultado —paid=50, hours_before=50— es el caso que Hypothesis te reporta y con el que depurarás en la próxima lección.
Profundización: qué hiciste a mano en el módulo 1 (y por qué era lo mismo)
Vale la pena conectar esto con algo que ya sufriste. En el mini-proyecto del módulo 1 encontraste el mismo tipo de bug (el bono sin tope) con un bucle random, y tu bucle reportó un contraejemplo feo: 130.66 h, pagado=28113, reembolso=51165. Para entregarlo bien, lo encogiste a mano: razonaste que el bono se activa apenas pasadas las 48 horas, probaste puntos vecinos (49 h con 100, 99 y 98 centavos), y encontraste el mínimo 49 h, pagó 100, reembolsa 101.
Mira lo que hiciste, paso por paso, y compáralo con el algoritmo de esta lección:
- Partiste de un caso grande que falla (
130.66 h, 28113). — El paso 1 del bucle. - Propusiste versiones más simples (49 horas en vez de 130, 100 centavos en vez de 28113). — El paso 2.
- Comprobaste cuáles seguían fallando (a 100 centavos sí viola, a 99 no). — El paso 3.
- Te detuviste cuando no pudiste reducir más sin que el fallo desapareciera (48 horas ya no falla; 99 centavos ya no falla). — El paso 4.
Hiciste, con esfuerzo humano y razonando sobre la aritmética, exactamente lo que Hypothesis hace en 31 intentos automáticos y una fracción de segundo. La diferencia no está en el qué —el algoritmo es el mismo: proponer más simple, comprobar si aún falla, repetir— sino en el quién: tú, sudando y arriesgándote a equivocarte, versus la herramienta, incansable y sistemática. Y hay un detalle más: tú tuviste que entender el bono para saber qué reducir; Hypothesis no entiende nada, solo prueba candidatos. Que llegue al mismo mínimo sin comprender el código es lo que lo hace tan general. Aquello que en el M1 fue "la última pieza tediosa que le faltaba a tu chequeo a mano" es, aquí, gratis y automático.
(Un apunte sobre por qué tu mínimo a mano fue 49 h, 100 y el de Hypothesis es 50 h, 50: ambos son mínimos válidos —casos pequeños que fallan—, pero el encogido de la herramienta balancea las dos dimensiones a la vez, bajando horas y centavos en conjunto, mientras que tú fijaste las horas primero y minimizaste el precio después. Los dos son "el guiso mínimo que amarga"; simplemente hay más de una receta mínima, algo que la lección 6 retoma.)
Errores comunes
Creer que "mínimo" significa "el más pequeño posible", sin la condición de fallar. Qué pasa: alguien espera que el caso mínimo sea paid=0, hours_before=0 (lo más chico imaginable) y se confunde al ver paid=50. Por qué pasa: se olvida de que "mínimo" aquí es "mínimo entre los que fallan", no mínimo a secas. Cómo detectarlo: si te preguntas "¿por qué no bajó el paid hasta 0?", te falta la condición de fallo. Cómo corregirlo: recuerda la cocina: la olla vacía no amarga, así que no sirve; se busca la olla mínima que conserva el amargor. Con paid=49 el bug quizás ya no aparece, así que 50 es el suelo. El caso mínimo es la frontera del fallo, no el cero absoluto.
Pensar que el encogido "entiende" el código para reducir. Qué pasa: alguien asume que Hypothesis analiza refund_cents, ve el umbral de 48 horas y salta directo al mínimo. Por qué pasa: el resultado es tan certero que parece que la herramienta comprende el bug. Cómo detectarlo: si te sorprende que el encogido funcione igual de bien en una función que Hypothesis "no podría entender", es señal de que le atribuyes comprensión que no tiene. Cómo corregirlo: interioriza que el encogido es una búsqueda a ciegas —propón más simple, comprueba si aún falla, repite— sobre una caja negra que solo responde sí o no. No entiende nada; prueba mucho. Por eso sirve para cualquier función sin cambios.
Ignorar que el encogido cuesta ejecuciones (y por tanto tiempo). Qué pasa: alguien tiene un test lentísimo, ve que "tarda una eternidad en fallar" y culpa a la generación, cuando el tiempo se va en el encogido. Por qué pasa: no cae en que reducir el caso implica volver a correr la propiedad decenas de veces (31 en nuestro ejemplo). Cómo detectarlo: si un test que falla tarda mucho más que uno que pasa, buena parte de esa diferencia es el encogido corriendo tu función una y otra vez. Cómo corregirlo: si el encogido de un test lento se vuelve un problema, la lección 7 te da las herramientas —bajar max_examples, ajustar deadline, o incluso limitar las fases— para controlarlo. Por ahora, entiende que el encogido no es gratis en tiempo, solo en esfuerzo humano.
Ejercicios
Ejercicio 1
Define el shrinking con tus propias palabras, en una sola frase, y luego responde: en la frase "el caso mínimo es el más simple que aún falla", ¿por qué las dos últimas palabras —"aún falla"— son imprescindibles? ¿Qué pasaría con el resultado si se las quitáramos?
Ver solución
Una definición válida: "el shrinking es el proceso por el que Hypothesis reduce un contraejemplo grande a la entrada más simple que todavía rompe la propiedad, probando versiones cada vez más simples y quedándose con las que siguen fallando".
Las palabras "aún falla" son imprescindibles porque son la condición que hace útil el resultado. Sin ellas, "el más simple" sería, sencillamente, la entrada más pequeña posible —paid=0, hours_before=0—, que casi seguro no reproduce el bug y por tanto no sirve para depurar. Es la olla vacía de la analogía: mínima, sí, pero muda. Con "aún falla", el encogido busca la entrada mínima que conserva el problema, que es la que apunta a la causa. Quitar "aún falla" convertiría el encogido en "hacer todo cero", inútil; conservarlo lo convierte en "encontrar la frontera exacta del fallo", que es toda su gracia.
Ejercicio 2
Corre el test_shrink_stats.py de la lección con --hypothesis-show-statistics y localiza la línea Tried N shrinks of which M were successful. Anota N y M. Luego responde: ¿qué representa N? ¿qué representa M? ¿por qué M es menor que N, y qué son los N - M intentos restantes?
Ver solución
Con derandomize=True deberías ver Tried 31 shrinks of which 8 were successful (N=31, M=8).
N=31es el número total de candidatos más simples que Hypothesis propuso durante el encogido —las 31 "versiones del guiso con menos ingredientes" que cocinó y probó—.M=8es cuántos de esos candidatos seguían fallando, y por tanto fueron aceptados como nuevo punto de partida para seguir reduciendo. Son los 8 pasos que efectivamente acercaron el caso al mínimo.M < Nporque no toda simplificación conserva el fallo: losN - M = 23intentos restantes fueron reducciones que hicieron que la propiedad volviera a pasar (aparecen como23 passingen la fase de shrink). Hypothesis los probó, vio que "quitaban el amargor", y los descartó. Es el precio normal de la búsqueda: para dar 8 pasos buenos hacia el mínimo, tuvo que explorar y rechazar 23 callejones sin salida. El total (31) es el esfuerzo; los exitosos (8) son el progreso.
Ejercicio 3
Sin correr nada, razona sobre el matiz de "mínimo local". El caso mínimo que Hypothesis reporta para este bug es paid=50, hours_before=50. A mano, en el módulo 1, encontraste paid=100, hours_before=49. Los dos fallan y los dos son pequeños. ¿Contradice esto la idea de "el caso mínimo"? Explica por qué puede haber más de un caso mínimo razonable y qué tienen en común.
Ver solución
No lo contradice; lo matiza. La definición dice "el más simple que Hypothesis pudo encontrar", no "el único mínimo que existe". Cuando el fallo depende de dos dimensiones a la vez (las horas y el precio), hay varias combinaciones pequeñas que lo reproducen, y cuál se alcanza depende del camino que tome la reducción. paid=50, hours_before=50 y paid=100, hours_before=49 son dos de esos mínimos: en el primero, Hypothesis bajó ambas dimensiones en conjunto; en el segundo, tú fijaste las horas justo por encima del umbral (49) y minimizaste el precio (100, el más chico donde el 1% redondea a un centavo). Ninguno es "más correcto" que el otro.
Lo que ambos tienen en común —y esto es lo esencial— es que los dos son fronteras del bug: casos tan chicos que quitarles un poco más hace desaparecer el fallo (hours_before=48 ya no falla; en paid=100, hours=49, bajar a 99 centavos ya no falla). Los dos dicen lo mismo sobre la causa: "el reembolso supera lo pagado apenas se cruza el umbral de 48 horas". Que existan varios mínimos no debilita el encogido; simplemente refleja que un bug de dos dimensiones tiene varias esquinas mínimas, y cualquiera de ellas sirve igual de bien para depurar. La lección 6 vuelve sobre por qué corridas distintas pueden aterrizar en esquinas distintas.
Resumen y siguiente paso
En esta lección definiste el shrinking con precisión: es el proceso por el que Hypothesis, tras hallar un contraejemplo, busca versiones más simples que también fallan y repite hasta no poder reducir más, entregándote el caso mínimo —el más simple que encontró que sigue rompiendo la propiedad—. Viste que "mínimo" no es "el más pequeño imaginable" sino "el más pequeño entre los que fallan", la frontera exacta del bug, como el guiso mínimo que aún amarga.
Entendiste cómo lo logra sin conocer tu código: tratando la propiedad como una caja negra que responde sí/no, y probando a ciegas candidatos más simples —una ignorancia deliberada que le permite encoger números, listas, fechas y objetos con el mismo mecanismo—. Y lo cuantificaste en las estadísticas: Tried 31 shrinks of which 8 were successful, el encogido hecho números —31 candidatos probados, 8 pasos buenos hacia el mínimo, el resto descartados por dejar de fallar—. Por último, reconociste en este algoritmo lo mismo que hiciste a pulso en el módulo 1, ahora automático y sin necesidad de entender la aritmética del bug.
Antes de avanzar deberías poder: definir el shrinking en una frase con la condición "aún falla"; explicar por qué el encogido no necesita leer tu código; e interpretar la línea Tried N shrinks of which M were successful.
En la siguiente lección dejamos la teoría y ponemos el encogido a correr sobre un bug de Reservo, de principio a fin. Vas a ver el contraejemplo crudo (grande), vas a ver el mínimo (chico), y vas a poder apagar el encogido con phases para comparar los dos con tus ojos. La idea ya la tienes; toca verla en acción.
Recursos
- How Hypothesis works — the shrinker (documentación oficial) — la explicación de los autores sobre el algoritmo de encogido: la versión técnica de la cocina de eliminación de esta lección. Confirma que la reducción es una búsqueda a ciegas sobre una caja negra.
- Anatomy of a property-based test (hypothesis.works) — desglosa las fases de un test de Hypothesis, incluida la de encogido, y ayuda a leer las estadísticas que vimos aquí.
- The purpose of Hypothesis (documentación oficial) — el porqué del proyecto; su insistencia en reportar el ejemplo mínimo explica por qué el encogido es central y no accesorio.