Skip to content

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 — extiende full-stack.yml con una segunda instancia de miniserve cargando 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:

$ uv run python scripts/demo/summarize_load.py experiments/39-load-and-shadow/load-baseline.json

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.py existe; summarize_load.py existe.
  • infra/compose/full-stack-shadow.yml existe; just demo-cold-up-shadow levanta 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.md escrito con ambas tablas.
  • dashboard-shadow.png comiteado.
  • test_load_dod.py pasando en CI.
  • Si está limitado por hardware, documentado en comparison.md con la concurrencia ajustada.

Trampas comunes

  1. 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.
  2. 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_flush de OTel.
  3. 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.
  4. Leer p95 del dashboard antes de que lleguen todos los datos. Espera 60 s tras terminar la carga; recomputa desde load-*.json como verdad fundamental.
  5. 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.