Módulo 5: Evals de regresión como gate de producción
El gate: PASS o FAIL el build
Descripción
Las lecciones 04, 05 y 06 construyeron, una por una, las tres preguntas del gate: ¿el resultado tiene la forma correcta? ¿el agente eligió la tool correcta? ¿el costo y la latencia se mantuvieron bajo el umbral? Esta lección las junta en las dos funciones que le dan a este módulo su nombre: run_case, que corre un caso individual del CASE_SET contra las cuatro comparaciones y devuelve un CaseResult completo; y run_regression_gate, que corre el CASE_SET entero y devuelve un GateReport con un veredicto PASS/FAIL para el lote — como un gate de integración continua, exactamente igual al que revisa cualquier pull request antes de dejarlo pasar.
Esta es la lección donde el gate corre, de punta a punta, por primera vez: primero limpio, con el CASE_SET tal como está —cinco de cinco, PASS—; después con una regresión real sustituida en uno de los cinco casos —cuatro de cinco, FAIL, con el caso exacto y el motivo exacto señalados—.
Conexión con el módulo
Esta lección ensambla CaseResult, GateReport, run_case y run_regression_gate — las últimas cuatro piezas de regression/harness.py. Con ellas completas, el módulo tiene, por primera vez, un artefacto que corre de punta a punta sobre el CASE_SET completo, listo para el mini-proyecto de la lección 08 y para la comparación de versiones del Módulo 7.
CaseResult y GateReport: el veredicto, estructurado
from dataclasses import dataclass, field
@dataclass
class CaseResult:
"""El veredicto de FORMA de un solo caso -- cuatro chequeos deterministas,
ninguno un juicio de calidad."""
name: str
passed: bool
actual_tools: list = field(default_factory=list)
tool_choice_ok: bool = True
schema_errors: list = field(default_factory=list)
output_errors: list = field(default_factory=list)
cost_cents: int = 0
cost_ok: bool = True
latency_ms: int = 0
latency_ok: bool = True
@dataclass
class GateReport:
"""El veredicto del CASE_SET completo -- PASS solo si los N casos
pasan los cuatro chequeos, como un gate de CI."""
cases: list = field(default_factory=list)
passed: bool = True
Nota, con atención, qué campos lleva CaseResult: no un solo booleano opaco, sino el detalle completo de cada chequeo — tool_choice_ok y actual_tools por separado, schema_errors y output_errors como listas (vacías si todo pasó), cost_cents/cost_ok y latency_ms/latency_ok como pares valor-veredicto. Esta estructura es la que hace posible que un FAIL, en la sección siguiente, señale exactamente qué se rompió — nunca un simple "algo falló" sin ninguna pista.
run_case: las cuatro preguntas, en un solo caso
def run_case(case, sequence_number, model_script=None):
"""Corre UN caso del CASE_SET: ejecuta run_reservo_agent (sin tocar su
lógica) con `model_script` (el del caso, o uno sustituido -- la pieza
que M7 reusa para comparar una versión vieja contra una nueva), y
aplica los cuatro chequeos de FORMA."""
reset_reservo_state()
script = model_script if model_script is not None else case["model_script"]
with rl.traced_run(case["question"], sequence_number) as trace_id:
final, history = ra.run_reservo_agent(case["question"], script)
tool_choice_ok, actual_tools = check_tool_choice(history, case["expected_tools"])
schema_errors = []
output_errors = []
if tool_choice_ok:
target_tool = case["expected_tools"][-1]
raw_result = _last_result_for_tool(history, target_tool)
target_result = json.loads(raw_result)
schema_errors = check_schema(target_result, OUTPUT_SCHEMAS[target_tool])
output_errors = check_expected_output(target_result, case["expected_output"])
cost_report = cost_for_run(trace_id, case["question"], history)
latency_ms = latency_for_run(history)
cost_ok = check_cost_threshold(cost_report.cost_cents, case["cost_threshold_cents"])
latency_ok = check_latency_threshold(latency_ms, case["latency_threshold_ms"])
passed = tool_choice_ok and not schema_errors and not output_errors and cost_ok and latency_ok
return CaseResult(
name=case["name"], passed=passed, actual_tools=actual_tools, tool_choice_ok=tool_choice_ok,
schema_errors=schema_errors, output_errors=output_errors,
cost_cents=cost_report.cost_cents, cost_ok=cost_ok,
latency_ms=latency_ms, latency_ok=latency_ok,
)
Fíjate en una decisión real, marcada con if tool_choice_ok:: los chequeos de forma y de valor de salida solo tienen sentido si la tool que se espera validar de verdad se llamó. Si el agente eligió una tool distinta a la esperada —como en el FAIL de la lección 05—, no hay ningún resultado de la tool esperada que buscar dentro de history (_last_result_for_tool devolvería None, y json.loads(None) lanzaría un TypeError, no un FAIL controlado). En vez de dejar que ese error de programación se propague, run_case corta ahí: si la tool elegida ya está mal, el caso es FAIL de inmediato, sin necesidad —ni sentido— de seguir validando un resultado que nunca se produjo. passed es la conjunción de las cinco condiciones: tool correcta, sin errores de schema, sin errores de valor, costo bajo umbral, latencia bajo umbral — cualquiera de las cinco puede hacer fallar el caso completo.
run_regression_gate: el lote completo, con overrides opcionales
def run_regression_gate(case_set, overrides=None):
"""El gate: corre cada caso del CASE_SET fijo y agrega un PASS/FAIL
global -- como un gate de CI. `overrides` (opcional) sustituye el
model_script de casos puntuales por nombre -- la misma técnica de
run_case, aplicada al lote completo, para comparar 'la versión de
antes' contra 'la versión de después' (la pieza que M7 reusa)."""
overrides = overrides or {}
cases = [
run_case(case, i, model_script=overrides.get(case["name"]))
for i, case in enumerate(case_set, start=1)
]
return GateReport(cases=cases, passed=all(c.passed for c in cases))
overrides es un dict opcional, {nombre_del_caso: guion_sustituido} — para cualquier caso que no aparezca en ese diccionario, overrides.get(case["name"]) devuelve None, y run_case usa el guion original del caso, sin ningún cambio. Esto permite correr el CASE_SET completo con un solo caso sustituido, sin tener que reconstruir los otros cuatro a mano — exactamente la forma en que el Módulo 7 va a comparar una versión vieja del agente contra una nueva: el mismo CASE_SET, el mismo criterio, y solo el comportamiento bajo prueba cambia.
Ejemplo trabajado, parte 1: el gate limpio — PASS del lote completo
report = run_regression_gate(CASE_SET)
print("GATE:", "PASS" if report.passed else "FAIL", f"({sum(c.passed for c in report.cases)}/{len(report.cases)})")
for c in report.cases:
print(f" {c.name:38} {'PASS' if c.passed else 'FAIL'}")
Qué esperar:
GATE: PASS (5/5)
quote_focus_pro_3h PASS
quote_focus_basic_3h PASS
book_focus_pro_3h_ana PASS
book_boardroom_pro_1h_sofia PASS
book_and_cancel_studio_basic_1h_diego PASS
Cinco de cinco. report.passed es True porque all(c.passed for c in report.cases) — la misma disciplina de "un solo caso roto rompe el lote entero" que cualquier gate de CI real aplica: no existe un "PASS parcial" en este diseño. Si uno solo de los cinco casos falla, el build entero falla.
Ejemplo trabajado, parte 2: el gate con una regresión — FAIL del lote completo
Ahora, el mismo CASE_SET, sin ningún cambio, pero con el guion de quote_focus_pro_3h sustituido por el guion "después del cambio" de la lección 05 —el que salta la cotización y reserva directamente—:
regressed_script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Focus pro 3h para Ana."}]},
]
report2 = run_regression_gate(CASE_SET, overrides={"quote_focus_pro_3h": regressed_script})
print("GATE:", "PASS" if report2.passed else "FAIL", f"({sum(c.passed for c in report2.cases)}/{len(report2.cases)})")
for c in report2.cases:
status = "PASS" if c.passed else "FAIL"
line = f" {c.name:38} {status}"
if not c.passed:
line += f" -- tool_choice_ok={c.tool_choice_ok} actual_tools={c.actual_tools}"
print(line)
Qué esperar:
GATE: FAIL (4/5)
quote_focus_pro_3h FAIL -- tool_choice_ok=False actual_tools=['book_room']
quote_focus_basic_3h PASS
book_focus_pro_3h_ana PASS
book_boardroom_pro_1h_sofia PASS
book_and_cancel_studio_basic_1h_diego PASS
Cuatro de cinco. report2.passed es False — el build entero falla, aunque solo uno de los cinco casos se rompió. El mensaje señala, sin ambigüedad, cuál: quote_focus_pro_3h, con tool_choice_ok=False y actual_tools=['book_room'] — la misma información exacta que un mensaje de FAIL de cualquier suite de pruebas real tendría que dar para ser útil: qué caso, cuál chequeo, qué se obtuvo en vez de lo esperado. Los otros cuatro casos, sin ningún cambio, siguen en PASS — la prueba de que el overrides afecta únicamente al caso nombrado, sin ningún efecto secundario sobre el resto del CASE_SET.
Leyendo un GateReport como se lee el resultado de un CI real
Vale la pena detenerse en el paralelismo, porque no es casual: un gate de CI real —el que corre en cualquier pull request de un repositorio serio— hace exactamente esto mismo, a mayor escala. Corre un conjunto fijo de pruebas, cada una con un criterio binario, y agrega un único veredicto: verde (todo pasó, el cambio puede fusionarse) o rojo (algo falló, hay que arreglarlo primero). Nadie espera que un CI "juzgue" si el código es elegante — espera que confirme, con certeza mecánica, que nada que ya funcionaba dejó de funcionar. run_regression_gate es esa misma disciplina, aplicada al comportamiento de un agente de LLM en vez de al comportamiento de una función de software tradicional. La lección 08 va a mostrar cómo ese veredicto se guarda en un archivo, regression_report.json, exactamente como cualquier CI real deja un artefacto —un log, un reporte de cobertura— para que alguien pueda revisarlo después, sin tener que volver a correr nada.
Y, como cada lección de este módulo, una última precisión antes de seguir: un GateReport.passed=False es, siempre, un veredicto de FORMA —tool elegida, schema, umbral—, nunca un juicio sobre si la respuesta del agente fue buena. Un GateReport en PASS tampoco certifica calidad — certifica, únicamente, que nada de lo que este gate sabe verificar se rompió. La pregunta de si el agente, más allá de la forma, está respondiendo bien sigue siendo, en toda esta guía, territorio de evaluation-frameworks-guide.
Errores comunes
-
Pensar que un
GateReport.passed=Falsesignifica que hay que revertir el cambio sin más análisis. El gate señala que algo cambió — el trabajo humano que sigue es decidir si ese cambio es una regresión real (revertir o arreglar el prompt) o un cambio de comportamiento intencional (actualizar elCASE_SETpara reflejar la nueva expectativa, con pleno conocimiento de causa). -
Olvidar que
overridessolo afecta al caso nombrado, y esperar que "contamine" el resto. El ejemplo trabajado, parte 2, lo demuestra explícitamente: los otros cuatro casos siguen en PASS, exactamente igual que en el gate limpio —overrides.get(case["name"])solo devuelve algo distinto deNonepara el caso cuyo nombre coincide. -
Correr
run_casedirectamente en vez derun_regression_gatecuando se necesita el veredicto del lote completo.run_caseda el detalle de un caso;run_regression_gatees la que agrega elpassedglobal conall(...). Confundir ambas —por ejemplo, revisar solo el primerCaseResulty asumir que representa a todo el lote— pierde exactamente la propiedad que hace útil a un gate: el veredicto es sobre el conjunto, no sobre una muestra. -
Ignorar
schema_errors/output_errorscuandotool_choice_okya esFalse. Como notó la sección derun_case, esas dos listas quedan vacías ([]) cuando la tool elegida ya está mal — una lista vacía en ese contexto no significa "la forma es correcta", significa "no se llegó a revisar la forma, porque el primer chequeo ya falló". Leerschema_errors == []como "el schema pasó" sin revisar tambiéntool_choice_okpuede llevar a una conclusión equivocada. -
Pensar que agregar más casos al
CASE_SETautomáticamente hace el gate "mejor". Cada caso nuevo agrega cobertura sobre un comportamiento específico, pero también agrega tiempo de ejecución y superficie de mantenimiento (cada caso necesita suexpected_outputrevisado si la lógica de negocio cambia legítimamente). La decisión de qué casos incluir es, en sí misma, una decisión de diseño — cubrir cada tool al menos una vez, cada ancla de precio conocida, y cada secuencia crítica de varios pasos, como hace elCASE_SETde cinco casos de este módulo, es un punto de partida razonable, no una fórmula universal.
Ejercicios
Ejercicio 1: Provoca un FAIL con overrides sobre un caso distinto (Fácil)
Usando el guion "sin cancelar" del Ejercicio 1 de la lección 05, corre run_regression_gate con overrides={"book_and_cancel_studio_basic_1h_diego": no_cancel_script}. Confirma el veredicto del lote y el detalle del caso que falló.
Ver solución
no_cancel_script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Studio", "tier": "basic", "hours": 1, "member": "Diego"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Studio basic 1h para Diego."}]},
]
report = run_regression_gate(CASE_SET, overrides={"book_and_cancel_studio_basic_1h_diego": no_cancel_script})
print("GATE:", "PASS" if report.passed else "FAIL", f"({sum(c.passed for c in report.cases)}/{len(report.cases)})")
broken = next(c for c in report.cases if not c.passed)
print("caso roto:", broken.name, "tools obtenidas:", broken.actual_tools)
Salida esperada:
GATE: FAIL (4/5)
caso roto: book_and_cancel_studio_basic_1h_diego tools obtenidas: ['book_room']
Explicación: el mismo mecanismo de overrides de esta lección, aplicado a un caso distinto — el gate detecta, con la misma precisión, que la cancelación nunca ocurrió.
Ejercicio 2: Simula dos regresiones a la vez (Medio)
Corre run_regression_gate con overrides que sustituya dos casos a la vez: quote_focus_pro_3h (con el guion regresivo de esta lección) y book_and_cancel_studio_basic_1h_diego (con el guion "sin cancelar" del Ejercicio 1). Confirma que el gate reporta exactamente 3/5, con ambos casos rotos identificados.
Ver solución
report = run_regression_gate(CASE_SET, overrides={
"quote_focus_pro_3h": regressed_script,
"book_and_cancel_studio_basic_1h_diego": no_cancel_script,
})
print("GATE:", "PASS" if report.passed else "FAIL", f"({sum(c.passed for c in report.cases)}/{len(report.cases)})")
for c in report.cases:
if not c.passed:
print(" roto:", c.name)
Salida esperada:
GATE: FAIL (3/5)
roto: quote_focus_pro_3h
roto: book_and_cancel_studio_basic_1h_diego
Explicación: overrides no tiene ningún límite de cuántos casos puede sustituir a la vez — cada entrada del diccionario se aplica de forma independiente, y run_regression_gate sigue agregando el veredicto global con la misma regla (all(...)), sin importar si uno o varios casos fallaron.
Ejercicio 3: Construye un resumen de "por qué falló" agrupado por tipo de chequeo (Difícil)
Con el GateReport de la parte 2 del ejemplo trabajado (el guion regresivo de quote_focus_pro_3h), escribe una función summarize_failures(report) que, para cada caso con passed=False, clasifique la causa en una de cuatro categorías: "tool_choice", "schema", "output", "threshold" (esta última si cost_ok o latency_ok son False). Un caso puede tener más de una categoría si falló por más de una razón.
Ver solución
def summarize_failures(report):
"""Clasifica, por caso, en cuáles de las cuatro categorías de chequeo
falló -- útil para un resumen rápido de un GateReport grande."""
summary = {}
for c in report.cases:
if c.passed:
continue
reasons = []
if not c.tool_choice_ok:
reasons.append("tool_choice")
if c.schema_errors:
reasons.append("schema")
if c.output_errors:
reasons.append("output")
if not c.cost_ok or not c.latency_ok:
reasons.append("threshold")
summary[c.name] = reasons
return summary
print(summarize_failures(report2))
Salida esperada:
{'quote_focus_pro_3h': ['tool_choice']}
Explicación: en este caso, la única categoría rota es tool_choice — el agente eligió book_room en vez de get_quote, así que ni schema_errors ni output_errors llegaron a evaluarse (recordando el error común 4: quedan vacíos porque run_case corta antes, no porque hayan pasado), y los umbrales de costo/latencia del overrides nunca se probaron con este guion específico. Un CASE_SET más grande, con regresiones de distintos tipos a la vez, produciría un diccionario con más de una categoría por caso — exactamente el tipo de resumen que le ahorra a un equipo real tener que leer cada CaseResult completo para entender, de un vistazo, qué clase de problema tiene el build.
Resumen y siguiente paso
- Construimos
CaseResultyGateReport, las estructuras que capturan el veredicto detallado de un caso y del lote completo. - Construimos
run_case, que aplica las cuatro comparaciones (tool, schema, valor, umbrales) sobre un caso, yrun_regression_gate, que las agrega en un veredicto PASS/FAIL sobre elCASE_SETcompleto — con unall(...)que hace que un solo caso roto tumbe el build entero, la misma disciplina de cualquier gate de CI real. - Ejecutamos el gate limpio:
GATE: PASS (5/5), los cinco casos delCASE_SETsin ningún cambio. - Ejecutamos el gate con una regresión sustituida vía
overrides:GATE: FAIL (4/5), con el caso exacto (quote_focus_pro_3h) y el motivo exacto (tool_choice_ok=False,actual_tools=['book_room']) señalados sin ambigüedad.
Siguiente lección: 08 — Mini-proyecto: un gate de regresión para Reservo. Ensamblamos regression/harness.py y regression/golden_cases.json completos en un solo directorio, corremos el gate de punta a punta, y producimos regression_report.json — el artefacto que el Módulo 7 va a reusar para decidir si una nueva versión del agente está lista para producción.
Recursos adicionales
- Python —
dataclasses—CaseResultyGateReport, yfield(default_factory=list), la misma técnica ya usada enStepCost/CostReport(Módulo 3) para evitar el error clásico de un valor por defecto mutable compartido. - Python — la función
all()— El agregador exacto detrás deGateReport.passed:Truesolo si cada elemento de la secuencia lo es. - Python —
dict.getcon valor por defecto — La base deoverrides.get(case["name"]), que devuelveNone(y por lo tanto "usa el guion original") para cualquier caso no sustituido. - Anthropic — Building effective agents — Sobre por qué un procedimiento repetible de verificación, corrido antes de cada cambio, es una práctica central de cualquier sistema agentic confiable en producción.
- Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de esta lección, incluidos los dos veredictos completos del gate.