Módulo 7: Versionado y rollout seguro
GO o NO-GO
Descripción
Las lecciones 04 y 05 dejaron la evidencia completa: v1 pasa PASS (5/5); v2 falla FAIL (4/5), exactamente en quote_focus_pro_3h, y el chequeo que lo atrapó fue tool_choice_ok, con un efecto colateral real en latency_ok. Esta lección convierte esa evidencia en una decisión: una función, rollout_decision, que toma dos GateReport —el de la versión vieja, el de la versión nueva— y devuelve una sola palabra, "GO" o "NO-GO", junto con la lista exacta de casos que la justifican.
La regla es deliberadamente estricta, y esta lección la ejecuta contra dos escenarios: v2 (que la rompe) y una v3 hipotética que corrige la regresión (que la cumple). El contraste entre los dos resultados es el punto de la lección: la misma regla, aplicada sin cambios, produce decisiones opuestas según la evidencia.
Conexión con el módulo
Esta lección retoma el registro (lección 03) y la comparación (lección 04) y les da un propósito: una decisión no es útil si se queda como un GateReport que alguien tiene que leer caso por caso de memoria. rollout_decision es la pieza que traduce esa evidencia en una acción concreta, con una regla explícita que cualquiera puede auditar sin tener que confiar en el criterio de la persona que está mirando el reporte.
La regla, en una frase
Del DISEÑO de esta guía: v2 obtiene GO si pasa el gate igual o mejor que v1; obtiene NO-GO si rompe aunque sea un solo caso que v1 pasaba. Nota el matiz, porque es el corazón de la regla: no es "si el conteo total mejora o se mantiene", es "si ningún caso que antes pasaba ahora falla". La diferencia importa — una versión nueva podría arreglar un caso roto y romper otro, terminando con el mismo conteo total que antes, y aun así merecer un NO-GO, porque introdujo una regresión real en un caso que funcionaba.
def rollout_decision(gate_old, gate_new):
"""GO si la version nueva pasa el gate igual o mejor que la vieja: NUNCA
puede romper un caso que la vieja pasaba. NO-GO en caso contrario.
Devuelve (decision, lista_de_nombres_de_casos_rotos)."""
old_by_name = {c.name: c for c in gate_old.cases}
new_by_name = {c.name: c for c in gate_new.cases}
broken = [
name for name, old_c in old_by_name.items()
if old_c.passed and not new_by_name[name].passed
]
return ("NO-GO", broken) if broken else ("GO", [])
broken es una comprensión de lista que recorre solo los casos que gate_old.cases pasaba (if old_c.passed) y verifica si, en gate_new.cases, ese mismo name dejó de pasar. Un caso que gate_old ya fallaba, y que gate_new sigue fallando (o incluso arregla), nunca entra en broken — no es una regresión, es, en el peor de los casos, un problema que ya existía antes.
Ejecutando la decisión: v1 contra v2
decision, broken = rollout_decision(report_v1, report_v2)
print(f"v1: {'PASS' if report_v1.passed else 'FAIL'} ({sum(c.passed for c in report_v1.cases)}/{len(report_v1.cases)})")
print(f"v2: {'PASS' if report_v2.passed else 'FAIL'} ({sum(c.passed for c in report_v2.cases)}/{len(report_v2.cases)})")
print(f"rollout_decision(v1, v2) -> {decision}, casos_rotos={broken}")
Qué esperar:
v1: PASS (5/5)
v2: FAIL (4/5)
rollout_decision(v1, v2) -> NO-GO, casos_rotos=['quote_focus_pro_3h']
NO-GO, y la lista ['quote_focus_pro_3h'] deja constancia exacta de por qué — sin ambigüedad, sin necesidad de releer el reporte completo para entender la razón.
Ejecutando la decisión: v1 contra una v3 que corrige la regresión
Para ver la regla producir el resultado contrario, imagina que el equipo detecta el problema de v2 y publica una v3 con la instrucción de proactividad corregida —más precisa sobre cuándo aplica, sin afectar preguntas de solo cotización—. Su comportamiento, para los cinco casos del CASE_SET, vuelve a coincidir con el de v1: VERSION_OVERRIDES["v3"] = {}, sin ninguna sustitución.
VERSION_OVERRIDES["v3"] = {}
report_v3 = run_regression_gate(CASE_SET, overrides=VERSION_OVERRIDES["v3"])
decision_v3, broken_v3 = rollout_decision(report_v1, report_v3)
print(f"v3: {'PASS' if report_v3.passed else 'FAIL'} ({sum(c.passed for c in report_v3.cases)}/{len(report_v3.cases)})")
print(f"rollout_decision(v1, v3) -> {decision_v3}, casos_rotos={broken_v3}")
Qué esperar:
v3: PASS (5/5)
rollout_decision(v1, v3) -> GO, casos_rotos=[]
Misma función, mismos umbrales, ninguna línea de rollout_decision cambió entre las dos corridas — el resultado cambió porque la evidencia cambió. Esa es, exactamente, la propiedad que hace confiable a una regla explícita: no hay ningún juicio de última hora ni excepción de cortesía para una versión que "casi" funciona.
Errores comunes
-
Comparar solo el conteo total (
4/5contra5/5) en vez de los casos específicos. Dos versiones con el mismo conteo total pueden tener conjuntos de fallos completamente distintos — una que arregla un caso viejo y rompe uno nuevo se ve, en el conteo, igual que la versión anterior, pero introdujo una regresión real que la regla de esta lección sí detecta. -
Pensar que un
NO-GOsignifica que la versión nueva es "peor en general". No necesariamente —v2podría, en un escenario real, mejorar la experiencia en preguntas ambiguas que ningún caso delCASE_SETcubre.rollout_decisionno evalúa "mejor en general"; evalúa, con precisión, "¿rompió algo que ya funcionaba?". Son preguntas distintas, y la regla de esta lección solo responde la segunda. -
Permitir que una versión nueva "compre" el permiso de romper un caso arreglando otro. Ese es, precisamente, el matiz que la sección "La regla, en una frase" advierte — y el motivo por el que
brokense calcula caso por caso, nunca como una diferencia de conteos totales. -
Ejecutar
rollout_decisioncon los argumentos en el orden equivocado. La función asume que el primer argumento es la versión vieja (la línea base) y el segundo es la versión nueva (la que se está evaluando). Invertir el orden invierte el sentido completo de la comparación — un caso que la "nueva" (en realidad la vieja) pasaba y la "vieja" (en realidad la nueva) no, generaría una lectura exactamente al revés de la real. -
Tratar
GO/NO-GOcomo el final del proceso, sin registrar la decisión en ningún lado. Una decisión que no queda escrita en ningún lugar (elAGENT_CHANGELOG.mddel mini-proyecto de la lección 08) se pierde en cuanto termina la sesión de terminal — y la próxima persona que se pregunte "¿por qué seguimos en v1 si hay una v2?" no tiene forma de saberlo sin repetir todo el trabajo de esta lección.
Ejercicios
Ejercicio 1: Calcula la decisión a mano, sin ejecutar código (Fácil)
gate_old tiene tres casos: case_a (PASS), case_b (PASS), case_c (FAIL). gate_new tiene los mismos tres casos, todos con PASS. Sin ejecutar rollout_decision, decide qué devolvería: ¿GO o NO-GO? Después, confirma con código.
Ver solución
GO, con casos_rotos=[]. La regla solo mira los casos que gate_old pasaba (case_a, case_b) y verifica si siguen pasando en gate_new — ambos siguen en PASS, así que no hay ningún caso roto. case_c, que ya fallaba en gate_old, no cuenta para la decisión aunque siga fallando o se arregle — la regla nunca lo considera, porque no es una regresión, nunca fue un caso que funcionaba.
from regression.harness import CaseResult, GateReport
old = GateReport(cases=[
CaseResult(name="case_a", passed=True),
CaseResult(name="case_b", passed=True),
CaseResult(name="case_c", passed=False),
], passed=False)
new = GateReport(cases=[
CaseResult(name="case_a", passed=True),
CaseResult(name="case_b", passed=True),
CaseResult(name="case_c", passed=True),
], passed=True)
print(rollout_decision(old, new))
Salida esperada:
('GO', [])
Ejercicio 2: Una versión que arregla un caso y rompe otro sigue siendo NO-GO (Medio)
Construye dos GateReport con dos casos cada uno: en gate_old, quote_focus_basic_3h pasa y book_and_cancel_studio_basic_1h_diego falla. En gate_new, quote_focus_basic_3h falla (una regresión nueva) y book_and_cancel_studio_basic_1h_diego pasa (se arregló). Confirma que, aunque el conteo total es el mismo en ambos (1/2), rollout_decision devuelve NO-GO.
Ver solución
old2 = GateReport(cases=[
CaseResult(name="quote_focus_basic_3h", passed=True),
CaseResult(name="book_and_cancel_studio_basic_1h_diego", passed=False),
], passed=False)
new2 = GateReport(cases=[
CaseResult(name="quote_focus_basic_3h", passed=False),
CaseResult(name="book_and_cancel_studio_basic_1h_diego", passed=True),
], passed=False)
print("gate_old:", sum(c.passed for c in old2.cases), "/", len(old2.cases))
print("gate_new:", sum(c.passed for c in new2.cases), "/", len(new2.cases))
decision2, broken2 = rollout_decision(old2, new2)
print("decision:", decision2, "broken:", broken2)
Salida esperada:
gate_old: 1 / 2
gate_new: 1 / 2
decision: NO-GO broken: ['quote_focus_basic_3h']
Explicación: el conteo total (1/2 en ambas corridas) esconde que ocurrieron dos cambios opuestos: una mejora real (book_and_cancel_studio_basic_1h_diego se arregló) y una regresión real (quote_focus_basic_3h se rompió). Si rollout_decision comparara solo conteos totales, este escenario pasaría como "sin cambios netos" — un GO injustificado que dejaría pasar una regresión real solo porque otra cosa, sin relación, mejoró al mismo tiempo. La regla de esta lección, al mirar caso por caso, no permite ese intercambio.
Ejercicio 3: Diseña una variante de la regla que SÍ tolere un intercambio (Difícil)
Un equipo distinto podría decidir, deliberadamente, que un intercambio como el del Ejercicio 2 es aceptable si el conteo total mejora o se mantiene. Escribe rollout_decision_lenient(gate_old, gate_new) que implemente esa política alternativa (GO si la cantidad de casos que pasan en gate_new es mayor o igual a la de gate_old, sin importar cuáles casos específicos cambiaron), pruébala sobre el mismo escenario del Ejercicio 2, y explica en dos frases por qué esta guía no adopta esa política como la regla por defecto.
Ver solución
def rollout_decision_lenient(gate_old, gate_new):
"""Politica alternativa: GO si el conteo total no empeora, sin
importar que casos especificos cambiaron."""
old_n = sum(c.passed for c in gate_old.cases)
new_n = sum(c.passed for c in gate_new.cases)
return ("GO", []) if new_n >= old_n else ("NO-GO", [])
decision_lenient, _ = rollout_decision_lenient(old2, new2)
print("rollout_decision_lenient:", decision_lenient)
Salida esperada:
rollout_decision_lenient: GO
Explicación de por qué esta guía no la adopta: la política estricta (la que usa rollout_decision en el resto de este módulo) protege una propiedad específica y valiosa —que un usuario que hoy recibe un comportamiento correcto en un caso concreto nunca lo pierda solo porque otra parte del sistema mejoró—, mientras que la política permisiva permite exactamente ese intercambio, tratando a los usuarios de quote_focus_basic_3h y book_and_cancel_studio_basic_1h_diego como intercambiables entre sí. En un sistema real, esos son flujos de negocio distintos, y "en promedio mejoramos" no es un consuelo aceptable para quien, específicamente, empezó a recibir un comportamiento roto que antes funcionaba — que es, con precisión, la razón por la que el gate de regresión existe.
Resumen y siguiente paso
rollout_decision(gate_old, gate_new)implementa la regla del DISEÑO:GOsi la versión nueva nunca rompe un caso que la vieja pasaba;NO-GOen caso contrario, con la lista exacta de nombres de casos rotos como evidencia.- Ejecutado sobre
v1contrav2:NO-GO,casos_rotos=['quote_focus_pro_3h']— la misma regresión de las lecciones 04 y 05, ahora convertida en una decisión formal. - Ejecutado sobre
v1contra unav3corregida (PASS 5/5):GO,casos_rotos=[]— la misma función, sin cambiar una línea, produce el resultado opuesto porque la evidencia cambió. - La regla es deliberadamente estricta: nunca permite que una mejora en un caso "compre" el permiso de romper otro — cada caso se evalúa de forma independiente, sin promediar.
Siguiente lección: 07 — Haciendo rollback. Con la decisión NO-GO de v2 ya tomada, construimos rollback: cómo el sistema vuelve, de forma determinista, a la versión anterior — y por qué, en esta guía, eso es un cambio de puntero de una sola línea.
Recursos adicionales
- Python — comprensión de diccionarios y listas — La base de
broken = [... for ... if ...], el corazón derollout_decision. - Anthropic — Building effective agents — Sobre por qué los criterios de éxito de un agente deben ser explícitos y verificables, no impresiones subjetivas.
- Python —
dataclassesy comparación de campos — La forma deCaseResultyGateReportquerollout_decisionrecorre. - Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de esta lección.