English · Español
Lab 02 — Load y shadow¶
La demo de una sola petición prueba la corrección; este lab prueba el comportamiento bajo concurrencia. Diez clientes simultáneos durante 60 segundos, y un shadow del adapter LoRA de la Fase 38 corriendo en paralelo al baseline para comparar latencia y coste sin afectar al usuario.
Objetivo¶
Ejecutar un load test controlado (10 clientes concurrentes, 60 s) contra el stack en vivo de la demo. Verificar que el objetivo DoD de p95 < 5 s se mantiene. Simultáneamente ejecutar un shadow de la variante LoRA promovida en la Fase 38 — misma entrada, respuesta no devuelta al usuario, latencia + accuracy logueadas — y producir la screenshot del dashboard comparativo.
Por qué existe este lab¶
Una demo de una sola petición prueba que el stack funciona. Una demo de 10 concurrentes prueba que aguanta. Se verifican dos propiedades específicas que el lab de una sola petición no puede:
- Encolado bajo contención (Teoría 02 §Little's Law). A 10 concurrentes y respuesta media ~3.3 s, el sistema necesita ≥ 3 req/s de throughput sostenido. Si la profundidad de cola crece sin acotar, el panel p95 de la demo lo mostrará antes de que falle el test de DoD.
- Comparación shadow. La Fase 38 promovió una variante con LoRA tuneada; la Fase 39 verifica las mejoras de esa variante (accuracy, latencia, coste) en tráfico en vivo sin exponer a los usuarios a un riesgo de regresión.
Entregables¶
scripts/demo/load.py— generador de carga estilo wrk con 10 workers, ventana de 60 s; parametriza set de payloads, sample-rate, baseline vs shadow.infra/compose/full-stack-shadow.yml— extiendefull-stack.ymlcon una segunda instancia deminiservecargando el adapter LoRA shadow (volume mount distinto).experiments/39-load-and-shadow/load-baseline.json— log por petición de la ejecución baseline de 60 segundos.experiments/39-load-and-shadow/load-shadow.json— lo mismo para la ejecución shadow.experiments/39-load-and-shadow/comparison.md— tabla comparando baseline vs shadow en p50/p95/p99/coste/accuracy.experiments/39-load-and-shadow/dashboard-shadow.png— screenshot de Grafana con ambas variantes visualizadas.tests/integration/test_load_dod.py— pytest que afirma p95 < 5 s bajo 10-concurrentes.
Paso 1 — Generador de carga¶
scripts/demo/load.py:
import asyncio, httpx, time, json, random
from pathlib import Path
PAYLOADS = [json.loads(p.read_text()) for p in Path("scripts/demo/payloads").glob("happy-path-*.json")]
async def worker(client, base_url, results, deadline):
while time.time() < deadline:
payload = random.choice(PAYLOADS)
t0 = time.perf_counter()
try:
r = await client.post(f"{base_url}/v1/grammar/correct", json=payload, timeout=10.0)
t1 = time.perf_counter()
results.append({
"duration_ms": (t1 - t0) * 1000,
"status": r.status_code,
"cost_eur": r.json().get("metadata", {}).get("cost_eur"),
"trace_id": r.headers.get("X-Trace-Id"),
})
except Exception as e:
results.append({"duration_ms": None, "status": "error", "error": str(e)})
async def run(base_url, concurrency=10, duration_s=60):
deadline = time.time() + duration_s
results = []
async with httpx.AsyncClient() as client:
await asyncio.gather(*[worker(client, base_url, results, deadline) for _ in range(concurrency)])
return results
La carga es estado-estacionario (los workers entran en bucle, sin distribución entre-llegadas); para la demo es suficiente. Reading de la Fase 40: patrones de carga burst + distribuidos Poisson.
Paso 2 — Ejecución baseline¶
$ just demo-cold-up
$ uv run python scripts/demo/load.py --concurrency 10 --duration 60 \
--base-url http://localhost:8080 --output experiments/39-load-and-shadow/load-baseline.json
Leer la salida:
Esperado:
| Métrica | Objetivo | Típico |
|---|---|---|
| Total de peticiones | n/a | ~180 |
| Tasa de error | < 1% | 0 |
| Latencia p50 | < 3 s | ~3.0 s |
| Latencia p95 | < 5 s (DoD) | ~4.5 s |
| Latencia p99 | < 7 s | ~5.8 s |
| Coste medio / req | n/a | €0.00042 |
| Coste total (60 s) | n/a | €0.076 |
Si p95 ≥ 5 s, el check de DoD falla. Abre experiments/39-load-and-shadow/log.md e investiga:
- Panel de profundidad de cola: ¿subió? Usa el profile de batching continuo de la Fase 33.
- Latencia por etapa: ¿qué cola de etapa explotó? Decode es el sospechoso habitual.
- Utilización de CPU en el i5-8250U de Borja: al 100%, el cuello de botella es hardware, no código.
Si está limitado por hardware: documenta el límite en comparison.md y baja la concurrencia a 6. El objetivo de DoD "10 concurrentes" asume un servidor baseline; el portátil de Borja puede necesitar ajuste por las open-questions §6 del Plan.
Paso 3 — Setup de variante shadow¶
La Fase 38 promovió una variante LoRA cuyo adapter está en artifacts/lora/grammar-promoted-rev-sha:a1b2c3.safetensors. Para ejecutarlo como shadow:
infra/compose/full-stack-shadow.yml:
name: lynx-cortex-demo-shadow
include:
- full-stack.yml
services:
miniserve-shadow:
extends:
file: ./miniserve.yml
service: miniserve
container_name: lynx-miniserve-shadow
ports:
- "8081:8080"
environment:
- MINISERVE_VARIANT=shadow
- LORA_ADAPTER_PATH=/models/grammar-promoted-rev-sha:a1b2c3.safetensors
volumes:
- ../../artifacts/lora:/models:ro
El shadow escucha en :8081. El baseline sirve tráfico de usuario en :8080. El generador de carga envía a ambos:
# Modified load.py:
await asyncio.gather(
run(base_url="http://localhost:8080", ...), # baseline
run(base_url="http://localhost:8081", ...), # shadow (mismos payloads)
)
Crítico: la respuesta del shadow no se devuelve a un usuario real. Se loguea solo para comparación. La Fase 38 ya implementó este contrato; la Fase 39 cablea el enrutamiento a nivel de compose.
Paso 4 — Comparación lado a lado¶
experiments/39-load-and-shadow/comparison.md:
# Baseline vs Shadow — load run 2026-06-XX
## Setup
- Concurrency: 10
- Duration: 60 s
- Payloads: 5 happy-path sentences (random sampling)
- Baseline: base Mini-GPT (no LoRA)
- Shadow: Mini-GPT + LoRA `grammar-promoted-rev-sha:a1b2c3`
## Latency
| Percentile | Baseline | Shadow | Δ |
|---|---|---|---|
| p50 | 3.05 s | 3.20 s | +5% |
| p95 | 4.61 s | 4.92 s | +7% |
| p99 | 5.82 s | 6.18 s | +6% |
## Cost
| Metric | Baseline | Shadow | Δ |
|---|---|---|---|
| Mean cost / req | €0.00042 | €0.00045 | +7% |
| Total cost | €0.076 | €0.081 | +7% |
## Accuracy (vs Phase 20 ground-truth labels for sampled payloads)
| Metric | Baseline | Shadow | Δ |
|---|---|---|---|
| Correction accuracy | 0.76 | 0.91 | **+15pp** |
| Spanish-translation accuracy | 0.82 | 0.94 | +12pp |
## Verdict
Shadow trades ~7% on latency and cost for **+15pp accuracy**. CpQU (Phase 38)
should be the deciding metric — load shadow data into the Phase 38 CpQU
aggregator and check whether the trade is favorable.
Esta tabla es el artefacto más importante del Lab 02. La Fase 38 ya enseñó CpQU como la lente para decisiones de promoción; la Fase 39 lo hace operacional alimentándolo con datos de carga reales.
Paso 5 — Dashboard con ambas variantes¶
Los paneles del dashboard de Grafana ya aceptan una etiqueta variant. Tras la ejecución shadow, los paneles se dividen:
- El histograma de latencia tiene dos distribuciones superpuestas (baseline azul, shadow naranja).
- El panel de coste por petición muestra dos líneas.
- La barra apilada de latencia por etapa muestra dos columnas lado a lado.
Screenshot cuando ambos están poblados. Comitea como experiments/39-load-and-shadow/dashboard-shadow.png.
Paso 6 — Aserción DoD¶
tests/integration/test_load_dod.py:
def test_p95_under_5s_at_10_concurrent(stack):
"""DoD: p95 latency under 5 s with 10 concurrent clients."""
results = run_load(stack.base_url, concurrency=10, duration_s=60)
successful = [r for r in results if r["status"] == 200]
durations_ms = [r["duration_ms"] for r in successful]
p95 = np.percentile(durations_ms, 95)
assert p95 < 5000, f"p95={p95:.0f} ms exceeds 5000 ms DoD target"
Esto se ejecuta en CI en cada PR. Una regresión que empuje p95 por encima de 5 s bloquea el merge.
Qué pinta tiene "hecho"¶
-
scripts/demo/load.pyexiste;summarize_load.pyexiste. -
infra/compose/full-stack-shadow.ymlexiste;just demo-cold-up-shadowlevanta ambas instancias de miniserve. - Ejecución baseline 60 s × 10-concurrentes completada; resultados en
load-baseline.json. - Ejecución shadow 60 s × 10-concurrentes completada; resultados en
load-shadow.json. -
comparison.mdescrito con ambas tablas. -
dashboard-shadow.pngcomiteado. -
test_load_dod.pypasando en CI. - Si está limitado por hardware, documentado en
comparison.mdcon la concurrencia ajustada.
Trampas comunes¶
- Ejecutar shadow en el mismo proceso que baseline. El sentido es el aislamiento; si comparten proceso, las comparaciones de latencia son ruido. Contenedores separados, puertos separados.
- Olvidar hacer flush de los traces. Con tasa de petición alta, OTel agrupa; los últimos segundos de datos de trace pueden no aparecer en Tempo antes del teardown. Duerme 5 s antes del tear-down o envía un
force_flushde OTel. - Comparar accuracy en una muestra diminuta. 5 payloads × 60 s con muestreo aleatorio da ~36 hits por payload — suficiente para señal direccional, no para un número publicable. Documenta el tamaño de muestra; no sobrevendas el +15pp.
- Leer p95 del dashboard antes de que lleguen todos los datos. Espera 60 s tras terminar la carga; recomputa desde
load-*.jsoncomo verdad fundamental. - Promover el shadow dentro de la Fase 39. El veredicto CpQU alimenta el proceso de promoción de la Fase 38. La Fase 39 entrega los datos; la Fase 38 decide.
Siguiente: lab/03-security-runthrough.md — replay de tres filas del modelo de amenazas a través del servicio en vivo.