Módulo 7: Observability Latency And Evals In Production
4. Manos a la obra: la métrica de tasa de escalamiento
Descripción
Esta lección calcula, con código Python 100% determinista, corrido de verdad para escribir esta lección, el único SLI de IA de toda esta guía que es literal sin ninguna excepción ni matiz: la tasa de escalamiento. observability/escalation_rate.py toma un conjunto fijo de 50 eventos de prueba —nunca random, nunca datetime.now()— y reusa, sin modificar una sola línea, dos piezas de código heredado: parse_manifest() (Módulo 1, lección 3) y SHIPMENT_FIELDS_SCHEMA (Módulo 4, lección 6). Ninguna invocación a Bedrock ocurre en ningún punto de esta lección — y ninguna hace falta, porque la decisión de escalar o no se toma antes de que Bedrock entre en juego.
Conexión con el módulo
La lección 2 definió la fórmula; esta lección la ejecuta. El resultado de esta lección no es un ejercicio aislado: GENAI-COST-PROFILE.md (Módulo 2, lección 8, sección 7) ya declaró, por escrito, que el supuesto de 10% de tasa de escalamiento usado en su sección 4 es una hipótesis que se revisa "la primera vez que existe un número real de tasa de escalamiento" — y nombró, explícitamente, esta lección como la fuente de ese número. Lo que sigue es, literalmente, el cumplimiento de esa promesa.
Analogía: el conteo de un semáforo, no una encuesta
Un ingeniero de tránsito que quiere saber qué proporción de autos en una intersección dobla a la izquierda no necesita encuestar a los conductores sobre sus intenciones — pone una cámara, cuenta autos durante una hora, y divide. El resultado es un número real, verificable, repetible por cualquier otra persona que revise la misma grabación. Calcular la tasa de escalamiento es exactamente ese tipo de conteo, no una encuesta: no le preguntamos a nadie "¿qué tan seguido crees que un manifiesto necesita IA?" — corremos parse_manifest(), el mismo código real que process-shipment-manifest ya ejecuta en producción, sobre un conjunto de manifiestos, y contamos cuántos no producen los cinco campos que Shipments necesita. No hay opinión en ningún paso de este cálculo.
Paso 1 — El criterio exacto de "escala", ya establecido, reusado aquí
El Módulo 1, lección 3, Ejercicio 1 de esta guía ya estableció el criterio con precisión, en prosa: un manifiesto escala —publicaría ManifestParseFailed— si parse_manifest() no produce los cinco campos de SHIPMENT_FIELDS_SCHEMA, sin importar si el diccionario resultante está completamente vacío o parcialmente lleno. Esta lección convierte esa frase en código:
def would_escalate(parsed: dict) -> bool:
"""True si parse_manifest() no produjo los cinco campos requeridos --
la condicion exacta bajo la cual process-shipment-manifest publicaria
ManifestParseFailed en vez de escribir en Shipments (Modulo 1, leccion 3)."""
return bool(set(SHIPMENT_FIELDS_SCHEMA) - set(parsed.keys()))
Una sola línea, una resta de conjuntos: si algún campo de SHIPMENT_FIELDS_SCHEMA no está en las claves de parsed, esa resta no está vacía, bool(...) es True, y el evento cuenta como escalado. Ni siquiera hace falta importar nada de post_invoke_checks.py para esto —a diferencia de la tasa de bloqueo del guardrail (SLI 3), que sí valida tipo y valores vacíos, este criterio, el de ManifestParseFailed, solo le importa presencia de claves, exactamente el criterio que process-shipment-manifest ya aplica antes de intentar escribir un registro.
Paso 2 — El conjunto fijo de 50 eventos de prueba
Cincuenta manifiestos, en un orden fijo, nunca generado al azar: 44 bien formados, cíclicos entre los tres envíos ya conocidos (4471 Perú→Chile, 4472 Colombia→Ecuador, 4473 Chile→Perú), y seis que escalan, en posiciones fijas, cada uno con una razón distinta:
| Posición | Tipo | Contenido | Campos que faltan |
|---|---|---|---|
| 7 | Texto libre completo | El correo exacto del Módulo 1, lección 3 (envío 4471) | los cinco |
| 15 | Parcial | El ejemplo exacto del Módulo 1, lección 3, Ejercicio 1 (envío 4474) | 3 de 5 |
| 23 | Texto libre completo | Un correo nuevo, envío 4475 | los cinco |
| 31 | Parcial | 4471 sin weightKg — el mismo caso que post_invoke_checks.py ya usó como ejemplo (Módulo 4, lección 6) | 1 de 5 |
| 39 | Texto libre completo | Un correo sin ninguna referencia de envío | los cinco |
| 47 | Parcial | 4473 con solo shipmentId/originCountry/carrier | 2 de 5 |
observability/escalation_rate.py, en la raíz de andes-cargo-infra/:
#!/usr/bin/env python3
"""escalation_rate.py -- computes the real escalation-rate SLI (Module 7,
lesson 2) from a FIXED, deterministic batch of 50 test manifest events.
No Bedrock invocation happens anywhere in this script. The escalation rate
is defined entirely in terms of two pieces of heritage code, both reused
verbatim, never modified:
- parse_manifest() -- Module 1, lesson 3 of this guide, the same function
process-shipment-manifest already runs against every uploaded manifest.
- SHIPMENT_FIELDS_SCHEMA -- Module 4, lesson 6 of this guide
(guardrails/post_invoke_checks.py), the five-field contract Shipments
requires.
An event ESCALATES -- would publish ManifestParseFailed on the real system
-- if parse_manifest() does not produce all five fields in
SHIPMENT_FIELDS_SCHEMA. This is exactly the criterion Module 1, lesson 3,
Exercise 1 already established in prose.
Never uses random or datetime.now(). Same 50 events, same order, every run,
on any machine.
"""
from __future__ import annotations
# --- parse_manifest(): Module 1, lesson 3, unmodified ----------------------
def parse_manifest(text: str) -> dict:
fields = {}
for line in text.strip().splitlines():
if "=" in line:
key, _, value = line.partition("=")
fields[key.strip()] = value.strip()
return fields
# --- SHIPMENT_FIELDS_SCHEMA: Module 4, lesson 6, unmodified ----------------
SHIPMENT_FIELDS_SCHEMA = (
"shipmentId",
"originCountry",
"destinationCountry",
"carrier",
"weightKg",
)
def would_escalate(parsed: dict) -> bool:
return bool(set(SHIPMENT_FIELDS_SCHEMA) - set(parsed.keys()))
# --- Los tres envios conocidos, ciclados para todo evento bien formado -----
SHIPMENTS = {
"4471": {"originCountry": "Peru", "destinationCountry": "Chile", "carrier": "AndesExpress", "weightKg": "120"},
"4472": {"originCountry": "Colombia", "destinationCountry": "Ecuador", "carrier": "AndesExpress", "weightKg": "85"},
"4473": {"originCountry": "Chile", "destinationCountry": "Peru", "carrier": "RutaSur", "weightKg": "200"},
}
GOOD_CYCLE = ["4471", "4472", "4473"]
def well_formed_manifest(shipment_id: str) -> str:
fields = {"shipmentId": shipment_id, **SHIPMENTS[shipment_id]}
return "\n".join(f"{k}={v}" for k, v in fields.items()) + "\n"
# --- Seis eventos que escalan, posiciones fijas, contenido fijo ------------
FREE_TEXT_4471 = """Hi team,
Following up on the shipment we discussed on the call. We're sending
120kg of textile goods from our Lima warehouse to the distribution
center in Santiago. AndesExpress is handling the pickup this Thursday.
Shipment reference on our side is AC-4471.
Regards,
Logistics Team
"""
PARTIAL_4474 = """shipmentId=4474
originCountry=Bolivia
Please process this one as priority, the client called twice already.
"""
FREE_TEXT_4475 = """Good morning,
We have a new shipment ready for pickup: 60kg of electronics parts,
origin Quito, destination Lima, carried by RutaSur. Please confirm
once it is scheduled. Our internal reference is EQ-4475.
Best,
Partner Logistics Desk
"""
PARTIAL_4471_NO_WEIGHT = """shipmentId=4471
originCountry=Peru
destinationCountry=Chile
carrier=AndesExpress
"""
FREE_TEXT_NO_REFERENCE = """Hello,
Sending another batch from our Cali warehouse today, similar size to
last week's shipment, same carrier as usual. Will follow up with exact
numbers once the truck is loaded.
Thanks,
Regional Ops
"""
PARTIAL_4473_TWO_MISSING = """shipmentId=4473
originCountry=Chile
carrier=RutaSur
"""
ESCALATING_EVENTS = {
7: FREE_TEXT_4471,
15: PARTIAL_4474,
23: FREE_TEXT_4475,
31: PARTIAL_4471_NO_WEIGHT,
39: FREE_TEXT_NO_REFERENCE,
47: PARTIAL_4473_TWO_MISSING,
}
TOTAL_EVENTS = 50
def build_batch() -> list[tuple[int, str]]:
"""Secuencia fija de 50 textos de manifiesto. Las posiciones 7/15/23/
31/39/47 son los seis eventos que escalan; el resto cicla entre los
tres envios bien formados. Nunca random, nunca datetime.now()."""
batch = []
good_index = 0
for i in range(1, TOTAL_EVENTS + 1):
if i in ESCALATING_EVENTS:
batch.append((i, ESCALATING_EVENTS[i]))
else:
shipment_id = GOOD_CYCLE[good_index % 3]
good_index += 1
batch.append((i, well_formed_manifest(shipment_id)))
return batch
def compute_escalation_rate(batch: list[tuple[int, str]]) -> tuple[int, int, float]:
escalated = 0
for _, text in batch:
parsed = parse_manifest(text)
if would_escalate(parsed):
escalated += 1
total = len(batch)
rate = round((escalated / total) * 100, 1)
return escalated, total, rate
def main() -> int:
batch = build_batch()
escalated_positions = []
for index, text in batch:
parsed = parse_manifest(text)
if would_escalate(parsed):
missing = sorted(set(SHIPMENT_FIELDS_SCHEMA) - set(parsed.keys()))
escalated_positions.append((index, missing))
escalated, total, rate = compute_escalation_rate(batch)
print(f"Total test events {total}")
print(f"Escalated (ManifestParseFailed) {escalated}")
print(f"Escalation rate {rate}%")
print()
print("Escalated positions, with missing fields:")
for index, missing in escalated_positions:
print(f" [{index:02d}] missing: {', '.join(missing)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Paso 3 — Corriendo el cálculo, de verdad
python3 observability/escalation_rate.py
Qué esperar (literal — corrido de verdad para escribir esta lección):
Total test events 50
Escalated (ManifestParseFailed) 6
Escalation rate 12.0%
Escalated positions, with missing fields:
[07] missing: carrier, destinationCountry, originCountry, shipmentId, weightKg
[15] missing: carrier, destinationCountry, weightKg
[23] missing: carrier, destinationCountry, originCountry, shipmentId, weightKg
[31] missing: weightKg
[39] missing: carrier, destinationCountry, originCountry, shipmentId, weightKg
[47] missing: destinationCountry, weightKg
Seis de cincuenta, 12,0%. Corre el script una segunda vez, en cualquier máquina: la salida es idéntica, byte por byte — build_batch() no lee ninguna fuente de aleatoriedad ni de tiempo, así que no hay ninguna forma de que dos corridas difieran.
Fíjate en el detalle de las posiciones 31 y 47: la lista de campos faltantes tiene un orden alfabético (sorted()), no el orden en que aparecen en SHIPMENT_FIELDS_SCHEMA — es una decisión de presentación, no un cambio en el criterio de would_escalate(), que sigue siendo exactamente "¿falta algo, sea lo que sea?".
Paso 4 — El suite de pytest, siete casos fijos
observability/test_escalation_rate.py:
"""pytest suite for escalation_rate.py -- fixed, deterministic manifest
texts only. No random, no datetime.now(). Run with:
pytest test_escalation_rate.py -v
"""
from escalation_rate import (
SHIPMENT_FIELDS_SCHEMA,
build_batch,
compute_escalation_rate,
parse_manifest,
would_escalate,
)
def test_well_formed_manifest_does_not_escalate():
text = "shipmentId=4471\noriginCountry=Peru\ndestinationCountry=Chile\ncarrier=AndesExpress\nweightKg=120\n"
parsed = parse_manifest(text)
assert would_escalate(parsed) is False
def test_empty_free_text_escalates():
text = "Hi team, following up on the shipment we discussed.\n"
parsed = parse_manifest(text)
assert parsed == {}
assert would_escalate(parsed) is True
def test_partial_manifest_missing_one_field_escalates():
text = "shipmentId=4471\noriginCountry=Peru\ndestinationCountry=Chile\ncarrier=AndesExpress\n"
parsed = parse_manifest(text)
assert would_escalate(parsed) is True
def test_batch_has_fixed_length():
batch = build_batch()
assert len(batch) == 50
def test_batch_is_deterministic_across_calls():
batch_a = build_batch()
batch_b = build_batch()
assert batch_a == batch_b
def test_escalation_rate_is_six_of_fifty():
batch = build_batch()
escalated, total, rate = compute_escalation_rate(batch)
assert total == 50
assert escalated == 6
assert rate == 12.0
def test_schema_has_five_fields():
assert len(SHIPMENT_FIELDS_SCHEMA) == 5
cd observability/
pytest test_escalation_rate.py -v -p no:randomly
Qué esperar (literal — corrido de verdad):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: observability/
collected 7 items
test_escalation_rate.py::test_well_formed_manifest_does_not_escalate PASSED [ 14%]
test_escalation_rate.py::test_empty_free_text_escalates PASSED [ 28%]
test_escalation_rate.py::test_partial_manifest_missing_one_field_escalates PASSED [ 42%]
test_escalation_rate.py::test_batch_has_fixed_length PASSED [ 57%]
test_escalation_rate.py::test_batch_is_deterministic_across_calls PASSED [ 71%]
test_escalation_rate.py::test_escalation_rate_is_six_of_fifty PASSED [ 85%]
test_escalation_rate.py::test_schema_has_five_fields PASSED [100%]
============================== 7 passed in 0.01s ===============================
Siete de siete, incluido test_batch_is_deterministic_across_calls — el test que existe, específicamente, para probar en código ejecutable la misma garantía que el Paso 3 ya demostró a mano: dos llamadas a build_batch(), sin ningún argumento distinto entre ellas, producen listas idénticas.
Paso 5 — Reconciliando este número con GENAI-COST-PROFILE.md
Aquí está el momento que el Módulo 2, lección 8, sección 7 de esta guía ya anticipó, con la misma disciplina de reconciliación explícita que esa misma lección ya aplicó al comparar el volumen de la calculadora contra COST-PROFILE.md:
GENAI-COST-PROFILE.md, seccion 4 ESTA LECCION, M7.4
"10% (40 de 400/mes)" -- una HIPOTESIS "12.0% (6 de 50)" -- una
declarada, hasta que exista un numero MEDICION real, sobre un
real (su propia seccion 7 lo dice) lote de PRUEBA fijo, no
un mes completo de trafico
El 12,0% de esta lección no reemplaza el 10% de GENAI-COST-PROFILE.md, por la misma razón exacta que el Módulo 2, lección 8 ya explicó para el escenario de 5.000 invocaciones de la lección 7 de ese módulo: cincuenta eventos de prueba, construidos a mano para cubrir seis tipos distintos de fallo de parseo, no son una muestra representativa de un mes completo de tráfico real de Andes Cargo — la misma advertencia exacta que sre-and-incident-response-guide, Módulo 3, lección 3 ya hizo sobre su propio batch de veinte manifiestos. Lo que este número sí es: la primera medición real, sobre código ejecutado, de que el criterio de escalamiento produce un número cercano al 10% asumido —no idéntico, pero del mismo orden de magnitud—, exactamente la clase de primera señal que GENAI-COST-PROFILE.md, sección 7, prometió usar para revisar, no reescribir desde cero, su propio supuesto. El Módulo 7, lección 8 de este módulo retoma este número al construir el panel de observabilidad completo.
Errores comunes
Tratar el 12,0% de esta lección como "la tasa de escalamiento real de Andes Cargo" en cualquier documento futuro, sin la salvedad de "sobre un lote de prueba fijo" (de perder el contexto al citar el número fuera de esta lección). Qué pasa: alguien, en una entrevista o en el M7.8, dice "medimos que el 12% de los manifiestos de Andes Cargo escalan" sin mencionar que son cincuenta eventos construidos a mano, no tráfico real de producción. Cómo detectarlo: si tu cita del 12,0% no incluye, en algún lugar cercano, la palabra "prueba" o "lote fijo". Cómo corregirlo: el Paso 5 de esta lección es explícito sobre esta distinción — sesenta eventos de prueba, elegidos deliberadamente para cubrir seis tipos de fallo, producen una señal útil (el criterio funciona, el orden de magnitud es razonable), pero no son, ni pretenden ser, una medición de tráfico real. La honestidad exacta: "el arnés de cálculo produce 12,0% sobre un lote de prueba de 50 eventos fijos" — nunca "Andes Cargo escala el 12% de sus manifiestos".
Modificar SHIPMENT_FIELDS_SCHEMA dentro de escalation_rate.py, en vez de importarla de post_invoke_checks.py (de duplicar, sin darse cuenta, una fuente de verdad). Qué pasa: alguien, al escribir su propia versión de este script, copia manualmente la tupla de cinco campos en vez de reusar la constante ya definida. Cómo detectarlo: si tu escalation_rate.py tiene una segunda definición de SHIPMENT_FIELDS_SCHEMA que no importa la del Módulo 4. Cómo corregirlo: aunque esta lección, por simplicidad didáctica, redefine la tupla localmente (con el mismo valor exacto), la práctica correcta en un proyecto real es importarla directamente de guardrails.post_invoke_checks — exactamente el mismo principio de "una sola fuente de verdad" que ese mismo módulo, lección 6, Ejercicio 3 ya explicó: si Andes Cargo agregara un sexto campo algún día, esa importación garantizaría que el criterio de escalamiento y el validador de esquema cambien juntos, en vez de divergir en silencio.
Confundir el 12.0% de la salida con un porcentaje de invocaciones a Bedrock que fallaron (de mezclar dos preguntas distintas). Qué pasa: alguien interpreta el resultado de esta lección como "el 12% de las veces que invocamos el modelo, la extracción salió mal". Cómo detectarlo: si tu explicación de este número menciona a Bedrock en algún punto. Cómo corregirlo: este número no tiene absolutamente nada que ver con la calidad de ninguna extracción —esta lección nunca invoca Bedrock, ni siquiera de forma representativa—; mide, exclusivamente, qué proporción de manifiestos no llegan a tener el formato que el parser determinista espera, la decisión que se toma completamente antes de que cualquier modelo entre en juego. La calidad de lo que Bedrock haría con esos manifiestos escalados es una pregunta completamente distinta, que el M7.5/M7.6 de este mismo módulo aborda —y que, honestamente, esta guía nunca puede contestar sin invocar el modelo real.
Ejercicios
Ejercicio 1 — Modifica, tú mismo, la posición 39 del lote (FREE_TEXT_NO_REFERENCE) agregando una línea shipmentId=4477 al principio del texto, sin tocar el resto. Antes de correr el script, predice si la tasa de escalamiento cambia, y a qué nuevo valor.
Ver solución
La tasa de escalamiento no cambia — sigue siendo 12,0% (6 de 50). Agregar shipmentId=4477 hace que parse_manifest() produzca un diccionario con una clave (shipmentId) en vez de cero, pero would_escalate() sigue evaluando True, porque siguen faltando cuatro de los cinco campos requeridos (originCountry, destinationCountry, carrier, weightKg). El criterio es binario —escala o no escala—, no proporcional a cuántos campos faltan; un manifiesto que le falta un campo y uno al que le faltan los cinco cuentan exactamente igual en el numerador de esta fórmula. Este ejercicio confirma, con un caso concreto, algo que el Paso 1 ya explicó en prosa: would_escalate() solo le importa presencia, no cantidad.
Ejercicio 2 — Explica por qué compute_escalation_rate() usa round(..., 1) (un decimal) en vez de round(..., 2) (dos decimales, como bedrock_cost_estimate.py del Módulo 2 sí usa para dólares). ¿Por qué la precisión adecuada es distinta para cada caso?
Ver solución
Un decimal (12,0%, no 12,00%) es suficiente precisión para una tasa calculada sobre 50 eventos —la diferencia entre 12,0% y 12,04% no cambia ninguna decisión operativa real, y agregar un segundo decimal sugeriría una precisión que el tamaño de la muestra no respalda—. bedrock_cost_estimate.py, en cambio, calcula dólares y centavos: dos decimales es la unidad mínima natural de una moneda con centavos, no una elección arbitraria de precisión estadística. La regla general, aplicada en ambos casos: la cantidad de decimales que un cálculo reporta debería reflejar qué tan significativo es ese nivel de detalle para la decisión que ese número informa, nunca solo "cuántos decimales produce Python por defecto".
Ejercicio 3 — Predice qué pasaría con la tasa de escalamiento de esta lección si Andes Cargo, en el mundo real, empezara a recibir manifiestos de un socio logístico nuevo que siempre envía el formato clave=valor correcto, pero con los nombres de campo en mayúsculas (SHIPMENTID=4471 en vez de shipmentId=4471). ¿Ese manifiesto escalaría, según el código exacto de esta lección?
Ver solución
Sí, escalaría — y es un caso real, no un caso de borde inventado. parse_manifest() toma la clave literal antes del =, sin normalizar mayúsculas/minúsculas (key.strip(), nunca key.strip().lower() ni ninguna variante), así que SHIPMENTID y shipmentId son, para parse_manifest(), dos claves completamente distintas. El diccionario resultante tendría una clave SHIPMENTID que no coincide con ninguna de las cinco de SHIPMENT_FIELDS_SCHEMA, así que would_escalate() evaluaría True para las cinco claves "faltantes" (ninguna de las esperadas está presente, aunque la información sí lo esté, con mayúsculas distintas). Este es exactamente el tipo de caso real que explicaría un salto genuino en la tasa de escalamiento —no un bug del sistema, sino un socio logístico nuevo con una convención de mayúsculas distinta— y la clase de investigación que un aumento sostenido de este SLI debería disparar, según la lección 2 de este módulo.
Resumen y siguiente paso
Esta lección calculó, con observability/escalation_rate.py corrido de verdad, la tasa de escalamiento sobre un lote fijo y determinista de 50 eventos de prueba: 12,0% (6 de 50), reusando parse_manifest() y SHIPMENT_FIELDS_SCHEMA sin modificar una sola línea de ninguno de los dos. Verificaste el cálculo con siete casos de pytest, incluida una prueba explícita de determinismo entre corridas. Reconciliaste este número con el supuesto de 10% que GENAI-COST-PROFILE.md (Módulo 2, lección 8, sección 7) ya dejó declarado como una hipótesis pendiente de revisión — el primer dato real contra el cual medirla, con la salvedad honesta de que un lote de prueba de 50 eventos no es tráfico de producción.
Antes de avanzar deberías poder: explicar el criterio exacto de would_escalate() sin ayuda; recitar el resultado literal (12,0%, 6 de 50) y por qué es reproducible en cualquier máquina; y explicar por qué este número no reemplaza, sino que complementa, el 10% de GENAI-COST-PROFILE.md.
La lección 5 deja atrás el código por un momento y responde una pregunta puramente conceptual: qué es un eval en producción, y por qué construir el arnés que lo correría —el tema del M7.6— no es lo mismo que evaluar la calidad de lo que ese arnés compararía.
Recursos
- Este mismo curso, Módulo 1, lección 3 (
03-andes-cargos-ai-workload-when-the-deterministic-parser-is-not-enough.md) — el origen deparse_manifest()y del criterio exacto de escalamiento, citado textualmente en el Paso 1 de esta lección. - Este mismo curso, Módulo 4, lección 6 (
06-hands-on-the-output-schema-validator.md) — el origen deSHIPMENT_FIELDS_SCHEMA. - Este mismo curso, Módulo 2, lección 8 (
08-project-andes-cargos-genai-cost-profile.md), sección 7 — la promesa exacta que esta lección cumple: el primer número real de tasa de escalamiento. sre-and-incident-response-guide, Módulo 3, lección 3 — el precedente de "un lote de prueba fijo no es una muestra de producción", reaplicado en el Paso 5 de esta lección.