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

  1. Pensar que un GateReport.passed=False significa 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 el CASE_SET para reflejar la nueva expectativa, con pleno conocimiento de causa).

  2. Olvidar que overrides solo 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 de None para el caso cuyo nombre coincide.

  3. Correr run_case directamente en vez de run_regression_gate cuando se necesita el veredicto del lote completo. run_case da el detalle de un caso; run_regression_gate es la que agrega el passed global con all(...). Confundir ambas —por ejemplo, revisar solo el primer CaseResult y 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.

  4. Ignorar schema_errors/output_errors cuando tool_choice_ok ya es False. Como notó la sección de run_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ó". Leer schema_errors == [] como "el schema pasó" sin revisar también tool_choice_ok puede llevar a una conclusión equivocada.

  5. Pensar que agregar más casos al CASE_SET automá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 su expected_output revisado 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 el CASE_SET de 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 CaseResult y GateReport, 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, y run_regression_gate, que las agrega en un veredicto PASS/FAIL sobre el CASE_SET completo — con un all(...) 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 del CASE_SET sin 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

  1. Python — dataclassesCaseResult y GateReport, y field(default_factory=list), la misma técnica ya usada en StepCost/CostReport (Módulo 3) para evitar el error clásico de un valor por defecto mutable compartido.
  2. Python — la función all() — El agregador exacto detrás de GateReport.passed: True solo si cada elemento de la secuencia lo es.
  3. Python — dict.get con valor por defecto — La base de overrides.get(case["name"]), que devuelve None (y por lo tanto "usa el guion original") para cualquier caso no sustituido.
  4. 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.
  5. 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.