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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ Versionado: una entrada por **entrega académica** del semestre (no SemVer estri

## [Unreleased]

### Added — H4 fase 1: log de eventos JSONL append-only (RF-06 / RN-03 / RN-07 / CP-08, 2026-05-21)
- Nuevo port [`ports/repositorio_eventos.py`](core-python/src/sentinel_dispatch/ports/repositorio_eventos.py) con:
- `EventoLog` (Pydantic BaseModel frozen, `extra="forbid"`, validación strict) que representa un evento del log.
- `TipoEvento` (StrEnum cerrado de 7 valores) alineado a `docs/data-model.md`.
- `RepositorioEventos` (Protocol `@runtime_checkable`) que define `append(evento)`, `leer_todos()` y `filtrar(...)`. **No expone `update`/`delete`** por diseño — RN-03 y RN-07 estructurales.
- `EventoDuplicadoError(ValueError)` para idempotencia ante reintentos.
- Nuevo adapter [`adapters/repositorio_jsonl.py`](core-python/src/sentinel_dispatch/adapters/repositorio_jsonl.py) que implementa el port sobre archivo JSONL: abre con modo `"a"`, valida cada línea con Pydantic en escritura y lectura, dedupe por `evento_id` con set in-memory cargado desde disco, genera IDs únicos monotónicos con formato `EVT-<YYYYMMDDTHHMMSS>-<seq04>`.
- Nuevo módulo [`application/serializacion.py`](core-python/src/sentinel_dispatch/application/serializacion.py) que extrae `serializar_resultado_despacho` de `interfaces/cli/run_dataset_cmd.py`. Es **punto único de verdad** del schema RT-02 (ADR-0017) y del payload del evento `despacho_creado` (ADR-0018): bit-exactitud garantizada por construcción.
- CLI `sentinel run-dataset` ahora acepta flag opcional `--log-eventos PATH`: si presente, persiste un evento `despacho_creado` por incidente en el log canónico. Sin flag, el comportamiento RT-02 se preserva 100%.
- [ADR-0018](docs/architecture/decisions/0018-schema-evento-log.md) congela el schema del evento_log: 6 campos en raíz (`evento_id`, `timestamp_iso`, `tipo`, `despacho_id`, `incidente_id`, `operador`) + `payload` subobjeto. Incluye **spike de viabilidad CP-08** documentando que el adapter detecta modificación externa via `EventoDuplicadoError` (duplicación de línea) y `ValidationError` de Pydantic (schema drift) al reabrir.
- Tests: **22 nuevos** verdes — 16 UT del adapter (`tests/unit/adapters/test_repositorio_jsonl.py` Normal/Borde/Error/RN), 4 IT incluyendo spike CP-08 (`tests/integration/test_repositorio_jsonl_append_only.py::TestSpikeCP08`), 2 UT del CLI (`TestLogEventos`). Suite total **235/235** verde; cobertura global **91.93%** (`repositorio_jsonl.py` 100%, `serializacion.py` 96%, `repositorio_eventos.py` 90%).

### Changed — H4 fase 1
- `docs/quality/trazabilidad.md`: **RF-06, RN-03 y RN-07 marcados ✅** apuntando a las rutas reales de adapter + port y al spike CP-08. §5.7 actualizada con el estado real post-H4-1.
- `interfaces/cli/run_dataset_cmd.py`: la serialización del `ResultadoDespacho` se delega a `application.serializacion.serializar_resultado_despacho` (función pública, antes era `_serializar_resultado` local del módulo).

### Added — H3 fase 3: orquestador + saturación + fallback RN-02 (RF-10 / RN-02 / RN-08, 2026-05-19)
- Nueva capa `application/` con tres archivos:
- [`application/tipos.py`](core-python/src/sentinel_dispatch/application/tipos.py) — value objects inmutables `ResultadoDespacho`, `EstadoSaturacion`, `CandidataRedireccion` + enum `MotivoDespacho` (`OPTIMO` / `PENALIZADO` / `SUBOPTIMO_RN02` / `SATURACION`).
Expand Down
151 changes: 151 additions & 0 deletions core-python/src/sentinel_dispatch/adapters/repositorio_jsonl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Implementación JSONL append-only de :class:`RepositorioEventos` (ADR-0007, ADR-0018).

Persiste eventos del sistema en un archivo JSONL (una línea = un objeto
JSON validado por Pydantic). Cumple **RN-03** y **RN-07** por construcción:
el adapter sólo expone :meth:`append` + lecturas, no hay API de
actualización ni borrado.

Concurrencia (v1): un solo operador SAMU según ADR-0007. **No se usa
lock externo** (``fcntl`` o similar) porque no hay escritores concurrentes
reales. Si Fase 5 (simulación) llegara a escribir desde múltiples
threads, el adapter deberá envolverse en :class:`threading.Lock` o
similar; el cambio es trivial y se documentará en ADR-0018 §"Plan de
migración" cuando se gatille.

Idempotencia: :meth:`append` mantiene en memoria el set de ``evento_id``
ya vistos (cargados desde disco al construir el adapter) y rechaza
duplicados con :exc:`EventoDuplicadoError`. Esto previene escribir dos
veces el mismo evento ante un reintento del caller.
"""

from __future__ import annotations

import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from sentinel_dispatch.ports.repositorio_eventos import (
EventoDuplicadoError,
EventoLog,
TipoEvento,
)

if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path


_log = logging.getLogger(__name__)


class JsonlRepositorioEventos:
"""Adapter JSONL append-only del port :class:`RepositorioEventos`.

Args:
path: ruta absoluta al archivo JSONL. Se crea (con ``parents``)
si no existe en la primera llamada a :meth:`append`. Si ya
existe, los ``evento_id`` previos se cargan en memoria para
preservar la idempotencia.
"""

_path: Path
_evento_ids_vistos: set[str]
_secuencia: int

def __init__(self, path: Path) -> None:
self._path = path
self._evento_ids_vistos = set()
self._secuencia = 0
if path.exists():
for evento in self._iter_archivo():
self._evento_ids_vistos.add(evento.evento_id)

@property
def path(self) -> Path:
"""Path absoluto del archivo JSONL respaldado por este adapter."""
return self._path

# ------------------------------------------------------------------
# Port: append + lecturas
# ------------------------------------------------------------------

def append(self, evento: EventoLog) -> None:
"""Persiste un evento al final del JSONL. Idempotente por ``evento_id``.

Raises:
EventoDuplicadoError: si ``evento.evento_id`` ya está
presente en el log (sea por carga al construir el
adapter o por un ``append`` previo en esta misma
instancia).
"""
if evento.evento_id in self._evento_ids_vistos:
raise EventoDuplicadoError(
f"evento_id duplicado: {evento.evento_id!r} ya existe en el log."
)
self._path.parent.mkdir(parents=True, exist_ok=True)
linea = evento.model_dump_json()
with self._path.open("a", encoding="utf-8") as f:
f.write(linea + "\n")
self._evento_ids_vistos.add(evento.evento_id)
_log.debug("evento_log.append", extra={"evento_id": evento.evento_id, "tipo": evento.tipo})

def leer_todos(self) -> Iterator[EventoLog]:
"""Itera todos los eventos del log en orden de escritura."""
yield from self._iter_archivo()

def filtrar(
self,
*,
despacho_id: str | None = None,
tipo: TipoEvento | None = None,
) -> Iterator[EventoLog]:
"""Itera los eventos que matchean los criterios (AND lógico).

``None`` en un criterio significa "no filtrar por ese campo".
Implementación: scan lineal. Suficiente para el volumen del
proyecto (~30-50 eventos por simulación, ADR-0007).
"""
for evento in self._iter_archivo():
if despacho_id is not None and evento.despacho_id != despacho_id:
continue
if tipo is not None and evento.tipo is not tipo:
continue
yield evento

# ------------------------------------------------------------------
# Helpers de generación de evento_id (no parte del port)
# ------------------------------------------------------------------

def generar_evento_id(self, *, base_ts: datetime | None = None) -> str:
"""Genera un ``evento_id`` único monotónico para esta instancia.

Formato: ``EVT-<YYYYMMDDTHHMMSS>-<seq04>``. La secuencia es
in-memory por instancia del adapter; reabrir el log resetea la
secuencia pero los ``evento_id`` ya escritos siguen siendo únicos
gracias al timestamp del lado izquierdo del id.

Args:
base_ts: opcional; permite inyectar un timestamp determinístico
en tests. Si es ``None`` se usa ``datetime.now(UTC)``.

Returns:
String con formato ``EVT-YYYYMMDDTHHMMSS-NNNN`` único.
"""
ts = base_ts if base_ts is not None else datetime.now(UTC)
self._secuencia += 1
return f"EVT-{ts.strftime('%Y%m%dT%H%M%S')}-{self._secuencia:04d}"

# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------

def _iter_archivo(self) -> Iterator[EventoLog]:
"""Iterador interno: streaming del JSONL en disco."""
if not self._path.exists():
return
with self._path.open("r", encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not line:
continue
yield EventoLog.model_validate_json(line)
74 changes: 74 additions & 0 deletions core-python/src/sentinel_dispatch/application/serializacion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Serialización canónica del :class:`ResultadoDespacho` (ADR-0017).

El schema producido aquí es el contrato bit-exacto que (a) consume
``tools/compare_outputs.py`` para la validación dual Python↔Java (RT-02),
(b) embebe el adapter :class:`JsonlRepositorioEventos` en el ``payload``
del evento ``despacho_creado`` del log JSONL (ADR-0018).

**Mantener un único punto de verdad** evita drift entre el JSONL que
emite el CLI ``run-dataset`` y el que persiste el log de eventos. Si
este schema cambia, hay que actualizar ADR-0017 y ADR-0018 en el mismo
PR (y regenerar fixtures de RT-02).
"""

from __future__ import annotations

import math
from typing import TYPE_CHECKING, Any

from sentinel_dispatch.application.tipos import MotivoDespacho

if TYPE_CHECKING:
from sentinel_dispatch.application.tipos import ResultadoDespacho


def serializar_resultado_despacho(resultado: ResultadoDespacho) -> dict[str, Any]:
"""Convierte un :class:`ResultadoDespacho` al dict del schema ADR-0017.

Schema congelado (ADR-0017):

- ``incidente_id``: str.
- ``categoria_mpds``: str (valor del enum, e.g. "Alpha").
- ``unidad_seleccionada``: ``{"id": str}`` o ``null`` si saturación.
- ``despacho_suboptimo``: bool (``true`` solo para SUBOPTIMO_RN02).
- ``motivo``: str (valor del enum, e.g. "OPTIMO", "SATURACION").
- ``eta_segundos``: float o ``null`` si saturación.
- ``costo``: ``{"T_viaje": float, "penalizacion": float, "total": float}``
o ``null`` si saturación.
- ``ruta``: list[str] (IDs de nodo como strings; vacío en saturación).
"""
incidente = resultado.incidente
motivo = resultado.motivo
es_saturacion = motivo is MotivoDespacho.SATURACION

unidad_sel: dict[str, str] | None = None
eta: float | None = None
costo_dict: dict[str, float] | None = None

if not es_saturacion and resultado.elegida is not None and resultado.costo_elegida is not None:
unidad_sel = {"id": resultado.elegida.id}
costo_obj = resultado.costo_elegida
eta = costo_obj.t_viaje_s if math.isfinite(costo_obj.t_viaje_s) else None
t_viaje = costo_obj.t_viaje_s if math.isfinite(costo_obj.t_viaje_s) else 0.0
pen = costo_obj.penalizacion if math.isfinite(costo_obj.penalizacion) else 0.0
total = costo_obj.valor_total_s if math.isfinite(costo_obj.valor_total_s) else 0.0
costo_dict = {
"T_viaje": t_viaje,
"penalizacion": pen,
"total": total,
}

# Ruta de nodos serializada como strings para evitar drift de int64 en parsers
# JSON de otros lenguajes (Java Long, JS number). En saturación → []. (ADR-0017 §ruta)
ruta: list[str] = [str(n) for n in resultado.ruta_nodos]

return {
"incidente_id": incidente.id,
"categoria_mpds": incidente.categoria_mpds.value,
"unidad_seleccionada": unidad_sel,
"despacho_suboptimo": resultado.despacho_suboptimo,
"motivo": motivo.value,
"eta_segundos": eta,
"costo": costo_dict,
"ruta": ruta,
}
Loading
Loading