Módulo 5: Evals de regresión como gate de producción

El set fijo de casos

Descripción

La lección 02 confirmó, en aislamiento, que check_schema, check_tool_choice, check_cost_threshold y check_latency_threshold funcionan sobre datos escritos a mano. Un gate de regresión real, sin embargo, no corre sobre un solo caso improvisado cada vez — corre, siempre, sobre el mismo conjunto de casos, uno detrás de otro, cada vez que algo en el sistema cambia. Esta lección construye ese conjunto: regression/golden_cases.json, cinco casos fijos que cubren las cuatro tools de Reservo, cada uno con su pregunta, su guion de turnos, y el resultado exacto que se espera.

La palabra clave de esta lección es fijo. No hay ningún mecanismo, en ningún archivo de este módulo, que genere un caso nuevo, que lo modifique "un poco" para variar la prueba, ni que lo borre después de correrlo. El mismo archivo, corrido hoy, corrido dentro de un año, produce la misma pregunta, el mismo guion, y la misma comparación — la propiedad exacta que hace posible que un gate de regresión sea confiable.

Conexión con el módulo

Esta lección entrega regression/golden_cases.json completo —el segundo artefacto nuevo de este módulo, junto a regression/harness.py— y reset_reservo_state, la función que garantiza que ningún caso del set dependa del orden en que corrieron los demás. Las lecciones 04 a 07 corren, una y otra vez, exactamente estos cinco casos.


Por qué un set fijo, y no casos generados al momento

Podría parecer más "completo" generar casos nuevos cada vez que corre el gate — variar las horas, el nombre del miembro, la sala. Esta guía, como en cada módulo anterior, rechaza esa idea por la misma razón de siempre: la reproducibilidad. Si el CASE_SET cambiara entre una corrida y la siguiente, un FAIL de hoy podría convertirse en un PASS mañana sin que nada en el sistema haya cambiado —simplemente porque el caso que se generó al azar esta vez resultó más fácil—, y un PASS de hoy podría esconder una regresión real que un caso generado distinto sí habría atrapado. Un gate cuyo resultado depende de qué caso le tocó correr no es un gate — es una lotería con una calcomanía de aprobado pegada encima.

Un CASE_SET fijo tiene la propiedad inversa: si el gate pasaba ayer y falla hoy, la única explicación posible es que algo en el sistema cambió — nunca que el caso fue distinto. Esa es, con precisión, la propiedad que hace que un FAIL sea información útil, en vez de ruido.


La forma de un caso

Cada entrada de golden_cases.json es un objeto JSON con siete campos, todos con datos ya familiares de esta guía —el mismo formato de question/model_script que cada lección anterior usa para el guion del modelo (concepto)—, más tres campos nuevos que declaran la expectativa exacta de este módulo:

{
  "name": "quote_focus_pro_3h",
  "question": "¿Cuánto cuesta Focus pro 3h?",
  "model_script": [
    {"stop_reason": "tool_use", "content": [
      {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
       "input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
    {"stop_reason": "end_turn", "content": [
      {"type": "text", "text": "Focus pro 3h cuesta $60.00."}]}
  ],
  "expected_tools": ["get_quote"],
  "expected_output": {"price_cents": 6000},
  "cost_threshold_cents": 5,
  "latency_threshold_ms": 100
}
  • name — un identificador legible, único dentro del CASE_SET, que va a aparecer en cada mensaje de PASS/FAIL de las lecciones que siguen.
  • question y model_script — exactamente lo que run_reservo_agent necesita para correr este caso: la pregunta y el guion de turnos del modelo (concepto), con el mismo formato de siempre.
  • expected_tools — la secuencia literal de tools que check_tool_choice (lección 05) va a exigir.
  • expected_output — un dict de pares clave-valor que el resultado de la última tool llamada debe cumplir exactamente — la ancla price_cents: 6000 de este caso es la misma ancla Focus-pro-3h que acompaña a esta guía desde el Módulo 1.
  • cost_threshold_cents y latency_threshold_ms — los umbrales fijos que check_cost_threshold/check_latency_threshold (lección 06) van a aplicar a este caso específico.

El CASE_SET completo de este módulo tiene cinco casos, cada uno anclado a un fragmento distinto del comportamiento de Reservo: dos cotizaciones (una con cada tier, ancladas a las dos anclas de precio de siempre — 7500 para basic, 6000 para pro), dos reservas completas (list_roomsget_quotebook_room, sobre dos salas distintas), y una reserva seguida de una cancelación.


load_case_set: leyendo el archivo, sin ninguna sorpresa

import json


def load_case_set(path):
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)

No hay nada que destacar en esta función — y esa es la idea. golden_cases.json es JSON puro: cada valor dentro de model_script (los bloques tool_use/tool_result/text, con sus strings, enteros y booleanos) es exactamente el mismo tipo de estructura que ya usaste, sin ninguna modificación, para escribir un guion de turnos a mano en cualquier lección anterior de esta guía. No hace falta ningún parser especial, ninguna clase propia — json.load es suficiente porque el CASE_SET nunca necesitó ser más que datos.

CASE_SET = load_case_set("golden_cases.json")
print("casos cargados:", len(CASE_SET))
print()
for c in CASE_SET:
    print(f"{c['name']:38} tools={c['expected_tools']}  cost<=+{c['cost_threshold_cents']}c  latency<={c['latency_threshold_ms']}ms")

Qué esperar:

casos cargados: 5

quote_focus_pro_3h                     tools=['get_quote']  cost<=+5c  latency<=100ms
quote_focus_basic_3h                   tools=['get_quote']  cost<=+5c  latency<=100ms
book_focus_pro_3h_ana                  tools=['list_rooms', 'get_quote', 'book_room']  cost<=+5c  latency<=250ms
book_boardroom_pro_1h_sofia            tools=['list_rooms', 'get_quote', 'book_room']  cost<=+5c  latency<=250ms
book_and_cancel_studio_basic_1h_diego  tools=['book_room', 'cancel_booking']  cost<=+5c  latency<=250ms

Confirma, además, que el archivo es JSON válido de punta a punta —leerlo y volverlo a serializar produce exactamente la misma estructura—:

raw = open("golden_cases.json", encoding="utf-8").read()
reparsed = json.loads(raw)
print("round-trip exacto:", reparsed == CASE_SET)
print("bytes del archivo:", len(raw))
round-trip exacto: True
bytes del archivo: 3923

reset_reservo_state: aislando cada caso del que corrió antes

reservo_tools.py tiene un estado compartido a nivel de módulo — BOOKINGS, el diccionario de reservas, y _booking_ids, el contador que asigna cada booking_id. Ese estado es exactamente lo que hace que book_room funcione (cada reserva necesita un id único), pero también es una trampa real para un CASE_SET con más de un caso que reserva: si dos casos corren, uno detrás de otro, en el mismo proceso de Python, sin ningún reseteo entre medio, el segundo caso hereda el contador del primero — su booking_id ya no sería el 1 que el caso declara en su expected_output, sino el que sea que el contador haya alcanzado.

import itertools
import reservo_tools as rt


def reset_reservo_state():
    """Resetea el estado compartido de Reservo (BOOKINGS + el contador de
    ids) antes de CADA caso, para que ningún caso dependa del orden en que
    corrieron los demás -- la misma disciplina de aislamiento de cualquier
    suite de tests."""
    rt.BOOKINGS.clear()
    rt._booking_ids = itertools.count(1)

Confirma el problema, y la solución, con los dos casos de reserva del CASE_SET —Ana y Sofía—:

def booking_id_of(case, sequence_number):
    reset_reservo_state()
    with rl.traced_run(case["question"], sequence_number) as trace_id:
        final, history = ra.run_reservo_agent(case["question"], case["model_script"])
    result = json.loads(_last_result_for_tool(history, "book_room"))  # helper de la lección 04
    return result["booking_id"]

case_ana = CASE_SET[2]      # book_focus_pro_3h_ana
case_sofia = CASE_SET[3]    # book_boardroom_pro_1h_sofia

print("booking_id de Ana   :", booking_id_of(case_ana, 1))
print("booking_id de Sofía :", booking_id_of(case_sofia, 2))

Qué esperar:

booking_id de Ana   : 1
booking_id de Sofía : 1

Ambos casos obtienen booking_id: 1 — coincide con lo que cada uno declara en su expected_output, sin importar el orden en que se corrieron. Ahora, la misma pareja de casos, pero sin reset_reservo_state entre uno y el otro:

reset_reservo_state()
with rl.traced_run(case_ana["question"], 1) as trace_id:
    final, history = ra.run_reservo_agent(case_ana["question"], case_ana["model_script"])
with rl.traced_run(case_sofia["question"], 2) as trace_id2:
    final2, history2 = ra.run_reservo_agent(case_sofia["question"], case_sofia["model_script"])  # SIN reset
result2 = json.loads(_last_result_for_tool(history2, "book_room"))
print("SIN reset entre casos, booking_id de Sofía:", result2["booking_id"])
SIN reset entre casos, booking_id de Sofía: 2

Sin el reseteo, el caso de Sofía hereda el contador que dejó el caso de Ana, y su booking_id real (2) deja de coincidir con el 1 que su expected_output declara — un FAIL que no tendría absolutamente nada que ver con ningún cambio real en el comportamiento de Reservo, solo con el orden en que los casos corrieron. Esa clase de FAIL —producido por el harness mismo, no por el sistema que se está evaluando— es exactamente lo que reset_reservo_state, corrida al principio de cada caso, elimina de raíz. Esta es la misma disciplina de aislamiento de cualquier suite de pruebas real: cada caso debe poder correr solo, o junto a cualquier otro, en cualquier orden, y producir siempre el mismo resultado.


Errores comunes

  1. Escribir expected_tools como un set en vez de una list, "porque el orden no debería importar". check_tool_choice (lección 05) compara con == contra una lista ordenada — el orden importa, a propósito: un agente que llama a book_room antes que a get_quote tiene un problema real, aunque termine llamando a las mismas dos tools. Un set perdería esa información.

  2. Olvidar reset_reservo_state y culpar al agente por un FAIL que en realidad produjo el propio harness. El ejemplo trabajado de esta lección lo demuestra con números: sin el reseteo, el segundo caso de reserva del CASE_SET falla su expected_output sin que nada en Reservo haya cambiado. Antes de investigar una supuesta regresión, confirma siempre que el harness está reseteando el estado correctamente.

  3. Modificar golden_cases.json "a mano, para un solo test rápido" y olvidar revertirlo. Como cualquier archivo fijo, un cambio no controlado deja de ser fijo — y el siguiente que corra el gate va a estar comparando contra una expectativa distinta a la que cualquier corrida anterior usó, sin ningún registro de qué cambió ni por qué.

  4. Pensar que reset_reservo_state también resetea run_logger._sequence o el trace_id. No lo hace, y no debería: el contador de secuencia de logs y el trace_id de cada caso son identificadores de observabilidad (Módulo 2), independientes del estado de negocio de Reservo (BOOKINGS). Mezclar ambos resets sería una confusión de capas — cada uno vive en su propio archivo, con su propia responsabilidad.

  5. Agregar un sexto caso al CASE_SET sin declarar los siete campos completos. Un caso sin cost_threshold_cents, por ejemplo, no produce un error visible al cargar el JSON —json.load no valida nada de esto—, pero sí produce un KeyError confuso más adelante, en la lección 07, cuando el harness intente leer un campo que no está. Esta lección no construye todavía esa validación (check_schema podría, en principio, aplicarse al CASE_SET mismo — una idea que el Ejercicio 3 explora).


Ejercicios

Ejercicio 1: Cuenta cuántos casos esperan cada tool (Fácil)

Sin abrir el archivo con un editor de texto, usa CASE_SET (ya cargado en Python) para contar en cuántos de los cinco casos aparece get_quote en algún punto de expected_tools, y en cuántos aparece cancel_booking.

Ver solución
quote_count = sum(1 for c in CASE_SET if "get_quote" in c["expected_tools"])
cancel_count = sum(1 for c in CASE_SET if "cancel_booking" in c["expected_tools"])
print("casos que usan get_quote    :", quote_count)
print("casos que usan cancel_booking:", cancel_count)

Salida esperada:

casos que usan get_quote    : 4
casos que usan cancel_booking: 1

Explicación: get_quote aparece en los dos casos de solo-cotización y en los dos de reserva completa (list_roomsget_quotebook_room) — cuatro de cinco. cancel_booking solo aparece en el quinto caso, el único que reserva y después cancela.

Ejercicio 2: Confirma que el reseteo también limpia una reserva de un caso anterior (Medio)

Reserva algo manualmente con rt.book_room(...) (fuera de cualquier caso del CASE_SET, simulando "trabajo previo" en el mismo proceso). Confirma que rt.BOOKINGS no está vacío. Llama a reset_reservo_state(). Confirma que rt.BOOKINGS vuelve a estar vacío, y que la siguiente reserva vuelve a obtener booking_id: 1.

Ver solución
rt.book_room(room="Studio", tier="basic", hours=1, member="Prueba previa")
print("BOOKINGS antes del reset:", rt.BOOKINGS)

reset_reservo_state()
print("BOOKINGS después del reset:", rt.BOOKINGS)

nueva = rt.book_room(room="Focus", tier="pro", hours=3, member="Ana")
print("nueva reserva tras el reset:", nueva)

Salida esperada:

BOOKINGS antes del reset: {1: {'booking_id': 1, 'room': 'Studio', 'tier': 'basic', 'hours': 1, 'member': 'Prueba previa', 'price_cents': 4000}}
BOOKINGS después del reset: {}
nueva reserva tras el reset: {'booking_id': 1, 'confirmed': True}

Explicación: reset_reservo_state no distingue entre "una reserva de un caso del CASE_SET" y "cualquier otra reserva hecha en el mismo proceso" — limpia el estado compartido de reservo_tools.py sin importar su origen, exactamente el comportamiento que hace que cada caso empiece siempre desde cero.

Ejercicio 3: Diseña check_case_shape, un chequeo de forma para el CASE_SET mismo (Difícil)

El error común 5 nombra un problema real: un caso mal formado en golden_cases.json no falla al cargarse, solo más adelante, con un error confuso. Escribe una función check_case_shape(case) que confirme que un caso tiene los siete campos requeridos (name, question, model_script, expected_tools, expected_output, cost_threshold_cents, latency_threshold_ms), devolviendo una lista de campos faltantes. Pruébala contra un caso completo del CASE_SET real, y contra un caso incompleto que armes a mano (sin latency_threshold_ms).

Ver solución
REQUIRED_CASE_FIELDS = [
    "name", "question", "model_script", "expected_tools",
    "expected_output", "cost_threshold_cents", "latency_threshold_ms",
]


def check_case_shape(case):
    """Chequeo de FORMA sobre el CASE_SET mismo: confirma que cada caso
    declara los siete campos que el harness necesita, ANTES de correrlo."""
    return [field for field in REQUIRED_CASE_FIELDS if field not in case]


incomplete_case = {"name": "roto", "question": "x", "model_script": [], "expected_tools": [],
                    "expected_output": {}, "cost_threshold_cents": 5}  # falta latency_threshold_ms

print("caso real         :", check_case_shape(CASE_SET[0]))
print("caso incompleto    :", check_case_shape(incomplete_case))

Salida esperada:

caso real         : []
caso incompleto    : ['latency_threshold_ms']

Explicación: este es, con precisión, el mismo patrón de check_schema (lección 02) aplicado a una capa distinta: en vez de validar la forma del resultado de una tool, valida la forma de un caso dentro del CASE_SET. Es una buena práctica correr un chequeo como este sobre los cinco casos, una sola vez, antes de intentar correr el gate completo — atrapa un CASE_SET mal formado con un mensaje preciso, en vez de dejar que el error aparezca, confuso, varios pasos después.


Resumen y siguiente paso

  • Construimos regression/golden_cases.json: cinco casos fijos, cada uno con su pregunta, su guion de turnos, la secuencia de tools esperada, el resultado exacto esperado, y sus umbrales de costo y latencia.
  • Confirmamos por qué "fijo" es la propiedad que hace que un gate de regresión sea confiable: si el CASE_SET no cambia, un FAIL solo puede significar que el sistema cambió.
  • Construimos reset_reservo_state, y confirmamos, con un caso real, que sin ella el orden de ejecución de los casos puede producir un FAIL que no tiene nada que ver con ninguna regresión real del agente.
  • load_case_set cierra el trabajo de esta lección: cinco casos, cargados como datos puros, listos para que las lecciones 04 a 07 los corran contra las cuatro funciones de la lección 02.

Siguiente lección: 04 — Forma, no calidad: la frontera. Con el CASE_SET ya construido, entramos a fondo en check_schema: el diccionario OUTPUT_SCHEMAS completo para las cuatro tools de Reservo, y la declaración más importante de todo el módulo — qué preguntas este chequeo nunca contesta, y por qué esas preguntas pertenecen a evaluation-frameworks-guide.


Recursos adicionales

  1. Python — jsonjson.load/json.dumps, la base completa de golden_cases.json y de load_case_set.
  2. Python — itertools.count — El contador que reset_reservo_state reconstruye desde cero antes de cada caso, la misma herramienta que ya usaste para _booking_ids desde agent-fundamentals M2.
  3. JSON Lines — El formato que este módulo evita a propósito para golden_cases.json: un CASE_SET es un arreglo único, no una secuencia de eventos independientes, así que JSON estándar (no NDJSON) es la elección correcta aquí.
  4. Anthropic — Building effective agents — Sobre por qué un conjunto de pruebas estable y repetible es la base de cualquier disciplina de confiabilidad para un sistema agentic.
  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.