Skip to content

English · Español

Lab 01 — Petición end-to-end del tutor de gramática

Una petición HTTP, las nueve capas. Se sigue el trace_id, se mide cada etapa, se verifica la identidad del coste y se confirma que cada panel del dashboard reacciona. Si una etapa no aparece en el trace, la arquitectura mentía. Lo arreglas en su fase de origen.

Objetivo

Enviar una petición canónica POST /v1/grammar/correct a través del stack en vivo del Lab 00. Capturar el árbol completo de traces. Verificar cada contrato de la Teoría 01 (arquitectura) y la Teoría 02 (flujo de datos). Calcular los números de identidad-de-coste y p95-extremo-a-extremo de la Teoría 03. Hacer que cada panel del dashboard se pueble en 60 s.

Entregables

  • experiments/39-end-to-end/request-walkthrough.md — una transcripción narrada de la única petición, con span IDs, tiempos y conteos de bytes.
  • experiments/39-end-to-end/trace-export.json — exportado desde Tempo para la petición canónica.
  • experiments/39-end-to-end/dashboard-screenshot.png — dashboard de Grafana con los 10 paneles poblados.
  • tests/integration/test_request_contracts.py — doce tests parametrizados, uno por contrato de la tabla de contratos de la Teoría 01.
  • tests/integration/test_cost_identity.py — aserción por petición de que la identidad de coste se mantiene dentro del 0.1%.
  • tests/integration/test_percentile_arithmetic.py — demostración de un solo disparo de la falacia (Teoría 02).

Paso 1 — La petición canónica

Payload: {"sentence": "Yesterday I goed to the store"}. Almacenado bajo scripts/demo/payloads/happy-path-001.json.

Envío manual (para que puedas leer cada pieza de la respuesta):

$ just demo-cold-up
$ curl -sS -X POST http://localhost:8080/v1/grammar/correct \
    -H "Content-Type: application/json" \
    -H "X-Request-Id: lab01-walkthrough-001" \
    --data @scripts/demo/payloads/happy-path-001.json | jq

Forma esperada de la respuesta (schema de la Fase 30):

{
  "correction": "Yesterday I went to the store",
  "explanation": "Past simple of 'go' is irregular: 'went'.",
  "spanish_translation": "Ayer fui a la tienda",
  "cited_chunks": ["irregular-go-past-001"],
  "metadata": {
    "trace_id": "...",
    "cost_eur": 0.00041,
    "duration_ms": 3287
  }
}

El trace_id es el handle para el resto del lab.

Paso 2 — Recorrer el trace

$ curl -sS "http://localhost:3200/api/traces/$TRACE_ID" | jq > experiments/39-end-to-end/trace-export.json

Spans esperados (de las nueve capas de la Teoría 02 + el contrato de la Teoría 01):

Nombre de span Padre Duración (típ) Atributos críticos
http.request (raíz) ~3,300 ms request_id, cost.eur, http.status_code
validate.body http.request ~0.3 ms
security.check http.request ~0.2 ms security.allow=true
tokenize.bpe http.request ~1.0 ms tokens.n=10
retrieve.hybrid http.request ~25 ms chunks.top=5, top.chunk_id=irregular-go-past-001
model.prefill http.request ~800 ms seq_len=80
model.decode http.request ~2,500 ms tokens.generated=30
format.json http.request ~2.0 ms schema.valid=true
cost.emit http.request ~1.5 ms cost.eur=0.00041, cost.identity_ok=true

Si falta un span: el diagrama de arquitectura mentía. Abre el BLUEPRINT.md de la fase originaria, encuentra dónde debería emitirse el span, archiva un fix-commit en esa fase. La Fase 39 no parchea.

Paso 3 — Doce tests de contrato

tests/integration/test_request_contracts.py recorre la tabla de contratos de la Teoría 01 §contracts (12 pares productor/consumidor). Un test parametrizado:

import pytest
from .contracts import CONTRACTS

@pytest.mark.parametrize("contract", CONTRACTS, ids=lambda c: c.id)
def test_contract_holds(stack, contract):
    """Each producer/consumer pair in the demo path satisfies its contract."""
    inputs = contract.fixture_inputs()
    output = contract.producer(*inputs)
    contract.consumer(output)        # raises on contract violation
    assert contract.invariant(output)

CONTRACTS es una lista de 12 dataclasses (construidas en el lab 00). Cada una tiene id, producer, consumer, fixture_inputs(), invariant().

Un fallo aquí significa que se disparó un test de integración por una deriva previamente no-sospechada. Abre el contrato; arregla en la fase originaria.

Paso 4 — Identidad de coste

tests/integration/test_cost_identity.py:

def test_cost_identity_within_tolerance(stack):
    """Sum of per-stage costs equals total cost within 0.1%."""
    response = send_canonical_request(stack)
    trace = fetch_trace(response.headers["X-Trace-Id"])
    stage_costs = sum(s.attributes["cost.eur"] for s in trace.spans
                      if s.attributes.get("cost.stage"))
    total_cost = trace.root_span.attributes["cost.eur"]
    assert abs(stage_costs - total_cost) / total_cost < 0.001, \
        f"identity violated: stages={stage_costs:.6f} total={total_cost:.6f}"

La primera ejecución podría fallar por un porcentaje — normalmente porque una etapa no está registrada con el emisor de coste, o su tiempo se cuenta doble. Recorre el caso fallido, identifica la etapa huérfana, arregla en la fase originaria, re-ejecuta.

Cuando el test pasa, anota en el log de experimento: 2026-06-XX — cost identity now holds within 0.04% on 5 consecutive runs.

Paso 5 — Demo de aritmética de percentiles

tests/integration/test_percentile_arithmetic.py:

import numpy as np

def test_percentile_arithmetic_fallacy_is_demonstrated():
    """Sum of per-stage p95s overstates the true end-to-end p95."""
    rng = np.random.default_rng(42)
    n = 10_000
    # Two correlated stages with realistic distributions.
    A = rng.lognormal(mean=0.0, sigma=0.5, size=n) * 1000  # ms
    B = rng.lognormal(mean=-0.3, sigma=0.4, size=n) * 800
    naive = np.percentile(A, 95) + np.percentile(B, 95)
    true = np.percentile(A + B, 95)
    overstatement = (naive - true) / true
    assert overstatement > 0.05, "expected naive p95 to overstate by >5%"
    assert overstatement < 0.30, "overstatement suspiciously large; check distributions"

Este es un test de demostración, no de regresión. Su trabajo es mantener la lección honesta en el código. El recorrido del lab captura los números impresos en request-walkthrough.md.

Paso 6 — Dashboard poblándose

Después de la petición canónica, espera 60 s y luego verifica cada panel:

$ uv run python scripts/audit_dashboard.py --dashboard capstone

El script (el Lab 00 lo escribió, este lab lo usa) consulta a Grafana por la query subyacente de cada panel, ejecuta la query contra Prometheus / Tempo y afirma resultado no vacío.

Modos de fallo:

  • "No Data" en panel de coste → el histograma de coste tiene cero observaciones (¿la petición no se completó? ¿el emisor de coste no disparó?). Inspecciona lynx_cost_eur_per_request_count directamente.
  • "No Data" en panel por etapa → la ingesta de trace se atrasó, o la tasa de muestreo no es 100%. Revisa el lag de ingesta de Tempo; sube la tasa de muestreo al 100% para la ejecución de la demo.
  • Conteo de orphan-spans > 0 → la propagación de contexto de trace se rompió. Identifica el huérfano; rastrea su padre esperado; arregla en la fase originaria.

Toma la screenshot del dashboard una vez los 10 paneles se pueblen. Comitea como experiments/39-end-to-end/dashboard-screenshot.png.

Paso 7 — request-walkthrough.md

Transcripción estructurada:

# Canonical request walkthrough — 2026-06-XX

## Request
POST /v1/grammar/correct
Body: {"sentence": "Yesterday I goed to the store"}
Trace ID: e21ab9...

## Stage timings (from trace)
| Stage | Duration | Bytes in / out |
|---|---|---|
| validate.body | 0.31 ms | 70 B / 70 B |
| security.check | 0.18 ms | n/a |
| tokenize.bpe | 1.04 ms | 30 B → 80 B |
| retrieve.hybrid | 26.5 ms | 80 B → 2 KB |
| model.prefill | 812 ms | 80 B → 5 MB (logits) |
| model.decode | 2421 ms | KV cache → 30 tokens |
| format.json | 1.91 ms | 80 B → 412 B |
| cost.emit | 1.43 ms | 412 B → 412 B + 2 KB metrics |
| **total** | **3263 ms** | — |

## Cost identity
Stage sum: €0.000412
Root span: €0.000412
Δ: 0.0% — OK

## Latency budget
Allocated 5,000 ms; consumed 3,263 ms; buffer 1,737 ms (35%).

## Findings
- All 12 contracts passed.
- All 9 spans present; trace tree well-formed.
- 0 orphan spans.
- Percentile-arithmetic demo: naive p95 sum overstated true p95 by 14.2% (n=10,000 synthetic samples).

Qué pinta tiene "hecho"

  • experiments/39-end-to-end/request-walkthrough.md comiteado con números completos.
  • experiments/39-end-to-end/trace-export.json comiteado.
  • experiments/39-end-to-end/dashboard-screenshot.png comiteado con los 10 paneles poblados.
  • tests/integration/test_request_contracts.py — 12 tests pasando.
  • tests/integration/test_cost_identity.py — pasando en 5 peticiones consecutivas.
  • tests/integration/test_percentile_arithmetic.py — pasando.
  • Todos los hallazgos de "span faltante" o "contrato roto" se arreglan en su fase originaria, no en la Fase 39.

Trampas comunes

  1. Parchear en la Fase 39. Como en el Lab 00. Cada fix aterriza en la fase originaria.
  2. Confiar en una sola petición happy-path. Envía la misma petición 5 veces; verifica el árbol de trace, la identidad de coste y la poblada del dashboard cada vez. Un éxito es anécdota.
  3. Mal-leer "No Data" como "panel roto". A veces el rango de tiempo es incorrecto; a veces la etiqueta de la métrica es lynx_cost_eur vs lynx_cost_eur_per_request. El script de auditoría reporta la query exacta que falla.
  4. Contar spans de validación/seguridad en el presupuesto de latencia. Son trabajo real, pero ~1 ms cada uno; para asignación de presupuesto, agrúpalos bajo una sola línea "overhead".
  5. Saltarse la demo de aritmética de percentiles porque "ya lo sé". El test está en la suite para prevenir que futuros contribuidores reconstruyan un panel ingenuo de suma-de-p95s. Es una barandilla.

Siguiente: lab/02-load-and-shadow.md — load de 10 concurrentes + la variante shadow del LoRA de la Fase 38.