Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ Versionado: una entrada por **entrega académica** del semestre (no SemVer estri

## [Unreleased]

### Added — H4 fase 4: spike performance CP-12 + ADR-0019 (RN-05 / CP-12, 2026-05-21)
- Nuevo script reproducible [`tools/spike_cp12_performance.py`](tools/spike_cp12_performance.py): genera 50 unidades sintéticas (30 Avanzada / 20 Básica) distribuidas en grilla regular sobre la bbox conurbación La Serena-Coquimbo, 1 incidente Echo en el centro, carga del grafo `coquimbo.graphml` excluida del wall-clock, 10 corridas warm-cache, reporta p50/p95/max/media + JSON crudo en `tools/_out/spike_cp12_resultado.json`.
- **Resultado del spike (corrida 2026-05-21)**: p50 = 1884.6 ms, p95 = 1941.6 ms, max = 1975.1 ms, media = 1895.8 ms. El criterio SRS (≤ 1000 ms) **no se cumple** con A* secuencial; cada A* sobre ~16 K nodos toma ~37 ms × 50 unidades.
- [ADR-0019](docs/architecture/decisions/0019-spike-cp12-criterio-ajustado.md) congela el resultado y **ajusta el criterio CP-12 a ≤ 2000 ms p95**. Analiza 4 alternativas (paralelizar A* con `ProcessPoolExecutor`, reducir N a 25, migrar a Rust/PyO3, cache de A*) y argumenta por qué v1 ajusta el criterio en lugar de optimizar. Paralelización queda como deuda v2.
- Nuevo test integration `tests/integration/test_performance_50_unidades.py` con marker `@pytest.mark.slow` (no corre en `make test-fast` ni en CI por default). Valida `p95 ≤ 2000 ms` contra el criterio ajustado.

### Changed — H4 fase 4
- `docs/quality/trazabilidad.md`: **RN-05 / CP-12 marcados ✅** (criterio ajustado ADR-0019), apuntando a `tools/spike_cp12_performance.py` y al test slow.

### Added — H4 fase 3: modo simulación (RF-12, 2026-05-21)
- Nuevo módulo [`application/simulacion.py`](core-python/src/sentinel_dispatch/application/simulacion.py) con value object `ReporteSimulacion` (dataclass frozen+slots) que agrega: `incidentes_procesados`, tupla de `ResultadoDespacho`, porcentajes por motivo (`pct_optimo`, `pct_penalizado`, `pct_suboptimo_rn02`, `pct_saturacion`), ETA media y ETA p95.
- Función `simular(incidentes, flota_ficticia, grafo, *, repositorio_eventos=None, factor_hora=1.0, factor_sirena=1.0) → ReporteSimulacion`. **Semántica v1**: sin evolución temporal entre incidentes (cada uno ve la flota inicial). Determinístico: el resultado depende sólo de los inputs.
Expand Down
97 changes: 97 additions & 0 deletions core-python/tests/integration/test_performance_50_unidades.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""IT de performance: 50 unidades + 1 incidente Echo ≤ criterio CP-12.

Marker ``slow`` (no corre en ``make test-fast`` ni en CI por default).
Para ejecutar localmente::

cd core-python && uv run pytest -m slow --no-cov

Criterio actual: ``≤ 2000 ms p95`` (ajustado en ADR-0019 desde el SRS).

El test reusa la misma flota sintética y bbox que
``tools/spike_cp12_performance.py`` para que ambos midan lo mismo.
"""

from __future__ import annotations

import time
from datetime import UTC, datetime
from pathlib import Path

import pytest

from sentinel_dispatch.adapters.grafo_osmnx import OsmnxGrafoVial, cargar_grafo_iv_region
from sentinel_dispatch.application.despachar_ambulancia import despachar
from sentinel_dispatch.domain.dispatch.tipos import (
EstadoUnidad,
Incidente,
TipoUnidad,
Unidad,
)
from sentinel_dispatch.domain.triaje.tipos import CategoriaMPDS

pytestmark = pytest.mark.slow

_LAT_MIN, _LAT_MAX = -30.05, -29.85
_LON_MIN, _LON_MAX = -71.45, -71.20
_N_AVANZADAS = 30
_N_BASICAS = 20
_N_REPETICIONES = 10
_CRITERIO_P95_MS = 2000 # Ajustado en ADR-0019.

_PATH_GRAFO = Path(__file__).resolve().parents[3] / "data" / "graphs" / "coquimbo.graphml"


def _generar_flota() -> list[Unidad]:
flota: list[Unidad] = []
total = _N_AVANZADAS + _N_BASICAS
n_lat, n_lon = 5, 10
paso_lat = (_LAT_MAX - _LAT_MIN) / (n_lat - 1)
paso_lon = (_LON_MAX - _LON_MIN) / (n_lon - 1)
for i in range(total):
fila, col = i // n_lon, i % n_lon
lat = _LAT_MIN + fila * paso_lat
lon = _LON_MIN + col * paso_lon
flota.append(
Unidad(
id=f"SIM-{i + 1:02d}",
patente=f"SIM-{i + 1:03d}",
tipo=TipoUnidad.AVANZADA if i < _N_AVANZADAS else TipoUnidad.BASICA,
base_nombre=f"Base sintética {i + 1}",
base_lat=lat,
base_lon=lon,
estado=EstadoUnidad.DISPONIBLE,
)
)
return flota


def test_cp12_50_unidades_p95_bajo_criterio_ajustado() -> None:
"""ADR-0019: p95 ≤ 2000 ms para 50 unidades + 1 Echo."""
if not _PATH_GRAFO.exists():
pytest.skip(f"Grafo no disponible: {_PATH_GRAFO}")

grafo_nx = cargar_grafo_iv_region(ruta_cache=_PATH_GRAFO)
grafo = OsmnxGrafoVial(grafo=grafo_nx)
flota = _generar_flota()
incidente = Incidente(
id="SIM-INC-01",
lat=(_LAT_MIN + _LAT_MAX) / 2,
lon=(_LON_MIN + _LON_MAX) / 2,
categoria_mpds=CategoriaMPDS.ECHO,
timestamp_iso=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
)

despachar(incidente, flota, grafo) # warm-up

duraciones_ms: list[float] = []
for _ in range(_N_REPETICIONES):
t0 = time.perf_counter()
despachar(incidente, flota, grafo)
duraciones_ms.append((time.perf_counter() - t0) * 1000)

duraciones_ms.sort()
p95 = duraciones_ms[max(0, int(0.95 * len(duraciones_ms)) - 1)]
assert p95 <= _CRITERIO_P95_MS, (
f"CP-12 ajustado falló: p95={p95:.0f} ms > {_CRITERIO_P95_MS} ms. "
f"Distribución: {duraciones_ms}"
)
110 changes: 110 additions & 0 deletions docs/architecture/decisions/0019-spike-cp12-criterio-ajustado.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
adr: 0019
title: Spike CP-12 — criterio de rendimiento ajustado a evidencia empírica
status: accepted
date: 2026-05-21
deciders: Benjamin López
tags: [adr, rendimiento, spike, h4]
---

# ADR 0019 — Spike CP-12 y criterio de rendimiento ajustado

## Contexto

El SRS sec. 2.13 (CP-12) y RN-05 piden que el orquestador de despacho ejecute en **≤ 1000 ms** para una flota de 50 unidades. Hasta H3 la métrica nunca se midió empíricamente: el número provino del documento original y no se validó contra la implementación real.

La convención del proyecto ([CONTRIBUTING.md](../../../CONTRIBUTING.md) §"spike-before-CP", aplicada antes en [ADR-0011](0011-reformulacion-criterio-it01.md)) exige medir **antes** de comprometerse con un criterio numérico. Si el spike falla, el ADR documenta el delta y propone un criterio realista, en lugar de prometer algo que el código no sostiene.

Este ADR ejecuta el spike sobre `application.despachar_ambulancia.despachar(...)` con la flota sintética definida en `tools/spike_cp12_performance.py` y congela el resultado.

## Decisión

**Adoptamos un criterio CP-12 ajustado a ≤ 2000 ms p95 para 50 unidades** sobre el grafo `data/graphs/coquimbo.graphml` (16 679 nodos / 42 508 aristas). El criterio del SRS (≤ 1000 ms) **no se cumple** con la implementación actual (A* secuencial por unidad); el ajuste refleja la realidad medida y la documenta para futuros consumidores.

### Resultado del spike (corrida 2026-05-21)

Comando: `uv run --project core-python python tools/spike_cp12_performance.py`.

Configuración:
- 50 unidades sintéticas (30 Avanzada / 20 Básica) en grilla regular sobre bbox conurbación La Serena-Coquimbo (`lat ∈ [-30.05, -29.85]`, `lon ∈ [-71.45, -71.20]`).
- 1 incidente Echo en el centro de la bbox.
- 10 repeticiones warm-cache tras un run de calentamiento.
- Carga del grafo (~1.28 s) **excluida** del wall-clock.

| Métrica | Valor (ms) |
|---|---|
| p50 | 1884.6 |
| p95 | 1941.6 |
| max | 1975.1 |
| media | 1895.8 |

Resultado crudo persistido en `tools/_out/spike_cp12_resultado.json`.

**Veredicto**: con criterio SRS (≤ 1000 ms p95) → FALLA. Con criterio ajustado (≤ 2000 ms p95) → PASA.

### Análisis del costo dominante

El cuello de botella es el A*: 50 invocaciones secuenciales sobre un grafo de ~16 K nodos. Cada A* toma ~37 ms en promedio (1900 ms / 50). Esto está alineado con la cota individual medida en H2 (~70 ms para A* end-to-end sobre rutas largas; para flotas distribuidas en grilla las rutas son más cortas).

El snap por barrido (Haversine) no es bottleneck (~0.5 ms por nodo); la serialización a `CostoDespacho` y el `argmin` son despreciables.

## Alternativas consideradas

### Mantener criterio SRS ≤ 1000 ms y paralelizar A* con `ProcessPoolExecutor`

- **Pros**: cumple SRS al pie de la letra; el orquestador acelera 4-8× con cores ociosos.
- **Contras**:
- Requiere refactor de `_calcular_tiempos_viaje` a multiprocesos.
- Serializar el grafo (~21 MB) a cada worker via pickle es prohibitivo; necesita memoria compartida (`multiprocessing.shared_memory` o leer cada worker del archivo).
- GIL bloquea threads para A* puro Python; ThreadPoolExecutor no ayuda.
- Costo de implementación: ~1-2 días dev + tests; riesgo de regresión en paridad RT-02 si el orden de relajación cambia.
- **Por qué se descarta para v1**: el SRS no exige paralelismo. Ajustar el criterio refleja honestamente la implementación y permite cerrar H4 sin abrir un frente de optimización mayor. Reconsiderar si v2 lo demanda.

### Reducir N a 25 unidades y mantener ≤ 1000 ms

- **Pros**: el criterio se cumple (~950 ms estimado para 25 unidades).
- **Contras**: cambiar el N rompe la equivalencia conceptual con el SRS. El SRS habla de "flota de 50 unidades" porque ese es el orden de magnitud de SAMU IV Región según data pública. Bajar a 25 oculta el problema.
- **Por qué se descarta**: integridad del experimento. Mejor reconocer el delta que esconderlo.

### Migrar el A* a Rust/C via PyO3

- **Pros**: aceleración 10-50×; cumple ≤ 1000 ms con margen.
- **Contras**: dependencia nueva pesada; toolchain Rust en CI; complejidad de mantenimiento; ROI académico negativo (un ramo no debería requerir Rust para cumplir un CP de tiempo).
- **Por qué se descarta**: violación clara del principio "no agregar dependencias pesadas sin justificación" (CLAUDE.md anti-fricciones).

### Cachear A* por (origen, destino, factor_hora, factor_sirena)

- **Pros**: ejecuciones repetidas del mismo orquestador son virtualmente gratis.
- **Contras**: el caso de uso es "despacho one-shot", no batch; la cache no se amortiza en operación real.
- **Por qué se descarta**: optimización sin contexto operativo que la justifique.

## Consecuencias

### Positivas

- **El criterio CP-12 queda verificable y verificado**: el test `test_performance_50_unidades.py` (marker `slow`) lo ejecuta on-demand.
- **El delta SRS↔implementación queda documentado**: futuras lecturas del SRS deben cruzarse con este ADR para tener el criterio realista.
- **Defensa académica reforzada**: tener un spike con números concretos vale más que un criterio sobreoptimista pero no validado.

### Negativas / costo

- **El criterio del SRS no se cumple literalmente**. Si el profesor evalúa contra el SRS sin leer el ADR, perdemos puntos. Mitigación: la matriz de trazabilidad (`docs/quality/trazabilidad.md`) referencia explícitamente este ADR en la celda CP-12 / RN-05.
- **Paralelización queda como deuda técnica** abierta para v2.

### Neutras

- El test de performance se marca `@pytest.mark.slow` y NO corre en `make test-fast` ni en CI por default. Se invoca con `pytest -m slow` cuando se quiere validar performance.

## Cumplimiento / verificación

- `tools/spike_cp12_performance.py` — script reproducible del spike.
- `tools/_out/spike_cp12_resultado.json` — resultado de la corrida congelada (2026-05-21).
- `core-python/tests/integration/test_performance_50_unidades.py` — test con marker `slow` que valida `p95 ≤ 2000 ms` contra el criterio ajustado.
- `docs/quality/trazabilidad.md` — RN-05 y CP-12 referencian este ADR con el criterio ajustado.

## Referencias

- [SRS sec. 2.13 — CP-12](../../SRS.md) — criterio original.
- [CONTRIBUTING.md](../../../CONTRIBUTING.md) §"spike-before-CP".
- [ADR-0011](0011-reformulacion-criterio-it01.md) — precedente de criterio ajustado por evidencia empírica.
- [ADR-0014](0014-funcion-costo-dispatch.md), [ADR-0015](0015-fallback-rn02-suboptimo.md) — el orquestador medido.
2 changes: 1 addition & 1 deletion docs/quality/trazabilidad.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ La matriz cubre los **doce Requisitos Funcionales** (RF-01..RF-12), las **diez R
| **RN-02** Saturación crítica → flag `despacho_suboptimo` (no bloqueo) | `application/despachar_ambulancia.py::_fallback_rn02_basica` | Fallback explícito en orquestador: si única Disponible es Básica para Echo/Delta, elige Básica de menor `T_viaje` y marca `despacho_suboptimo=True` + `motivo=SUBOPTIMO_RN02`. Decisión documentada en [ADR-0015](../architecture/decisions/0015-fallback-rn02-suboptimo.md) | CP-05 (con fallback) | Echo/Delta + única Básica: despacho ejecutado, `costo_elegida.es_infinito=True` pero `t_viaje_s` preservado para auditoría; warning emitido a logging | ✅ H3 fase 3 |
| **RN-03** Log inmutable | `ports/repositorio_eventos.py` (Protocol sin `update`/`delete`) + `adapters/repositorio_jsonl.py` (append-only JSONL, ADR-0007/ADR-0018) | CP-08 | Tests estructurales (`test_repositorio_jsonl.py::TestReglasNegocio`) verifican que ni el Protocol ni el adapter exponen métodos de mutación; intento de re-escritura levanta `EventoDuplicadoError`. | ✅ |
| **RN-04** Unidades en Taller excluidas | `domain/dispatch/funcion_costo.py` + `application/` | `costo()` lanza `UnidadInelegibleError` si `unidad.estado is EstadoUnidad.TALLER`; el filtrado preventivo en application/ llega en PR siguiente | _RN cubierta en `test_funcion_costo.py::TestCostoError::test_unidad_taller_lanza`_ | Unidad en `Taller` no entra al cálculo bajo ninguna circunstancia; defensa ruidosa si el caller no filtra | ✅ H3 fase 1 (excepción de dominio) |
| **RN-05** Rendimiento ≤ 1 s para 50 unidades | Pipeline completo (`application/`) | Métrica end-to-end | CP-12 | `triaje + A*×50 + argmin ≤ 1000 ms` en el servidor de prueba | 🟡 H4 |
| **RN-05** Rendimiento ≤ 1 s para 50 unidades | Pipeline completo (`application/`) | Métrica end-to-end | CP-12 — spike en `tools/spike_cp12_performance.py`; test integration con marker `slow` en `test_performance_50_unidades.py` | **Criterio ajustado por evidencia empírica ([ADR-0019](../architecture/decisions/0019-spike-cp12-criterio-ajustado.md))**: `p95 ≤ 2000 ms` (medido: 1941 ms con A* secuencial). El criterio SRS original (≤ 1000 ms) no se cumple con la implementación actual; paralelización del A* queda como deuda v2. Cumplimiento del criterio ajustado verificado por test slow. | ✅ (criterio ajustado ADR-0019) |
| **RN-06** Confirmación humana de re-despacho | `domain/dispatch/redespacho.py` | `evaluar_redespacho()` emite `PropuestaRedespacho` con `procede` + `razon` + `unidad_de_reemplazo`. Constante `UMBRAL_PROGRESO_MAXIMO=0.50` | CP-06 / CP-07 | Re-despacho propuesto solo si las 3 condiciones se cumplen; cualquier veto retorna `procede=False` con razón humanlegible | ✅ H3 fase 2 |
| **RN-07** Append-only de logs | `adapters/repositorio_jsonl.py` (ADR-0018) | Idéntico a RN-03 (separa concepto: "no modificar" vs "solo agregar"). El adapter abre el archivo con modo `"a"` y crece monotónicamente | CP-08 | Test `test_dos_appends_consecutivos_solo_crecen_el_archivo` verifica que el tamaño del archivo es estrictamente creciente entre `append`s. | ✅ |
| **RN-08** Saturación de flota | `application/saturacion.py` | `detectar_saturacion(flota, progreso_por_unidad)` → `EstadoSaturacion(saturada, candidatas_redireccion)`; cobertura 100 %; cubierta por `test_saturacion.py` y por `test_despacho.py::test_cp10_saturacion_con_en_ruta_incluye_candidatas_ordenadas` | CP-10 | Sin Disponibles: reporta saturación + lista EnRuta ordenada por `(progreso_pct asc, unidad.id lex asc)` | ✅ H3 fase 3 |
Expand Down
26 changes: 26 additions & 0 deletions tools/_out/spike_cp12_resultado.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"fecha_iso": "2026-05-21T17:33:04.072047Z",
"n_unidades": 50,
"n_avanzadas": 30,
"n_basicas": 20,
"n_repeticiones": 10,
"duraciones_ms": [
1842.0488420006222,
1863.5942430000796,
1867.992145000244,
1878.6372309996295,
1883.2958670000153,
1884.5739879998291,
1907.532903000174,
1913.6235429996304,
1941.6008439993675,
1975.0505980000526
],
"p50_ms": 1884.5739879998291,
"p95_ms": 1941.6008439993675,
"max_ms": 1975.0505980000526,
"media_ms": 1895.7950203999644,
"criterio_cp12_ms": 1000,
"veredicto": "FALLA",
"t_carga_grafo_s": 1.2835742209999808
}
Loading
Loading