Formato de resultados (RunResult) y event log
Fuente de verdad: LILA_MODELER_ESTRUCTURA.md, sección 6 ("Diseño del motor" → "Métricas (RunResult)" y "Event log") y sección 3 (checklist de paridad con Bizagi). Este documento detalla lo que ahí se resume: la estructura completa de RunResult, la fórmula o definición operativa de cada métrica, las columnas del event log con tipo y unidad, y el mapeo de nombres de columna internos a los nombres que usa Bizagi (que la CLI reutiliza al imprimir tablas).
Unidades, salvo que se indique otra cosa:
- Todo tiempo se mide y almacena en segundos (float64).
run.baseTimeUnitsolo afecta a cómo se presentan en CLI/UI; nunca al valor almacenado. - Todo dinero se mide en
run.currency(la moneda del escenario, verSCENARIO_FORMAT.md). - Los timestamps del event log son segundos de reloj virtual desde
run.start, salvo la variante ISO 8601 que se deriva derun.startsolo al exportar.
RunResult es la salida de simulate(ir, scenario, opts) (packages/engine/src/core/run.ts, ver sección 6 del documento de estructura). Se agrega tras correr una o más replicaciones (scenario.run.replications) y, cuando hay más de una, cada métrica numérica lleva además su resumen entre replicaciones (replications/ci95, ver más abajo).
Fallo durante la ejecución sin avance temporal (#368)
Si R-TOK-7 agota el presupuesto de trabajo de un caso, runReplication y simulate
lanzan E-LIMITE-SIN-AVANCE. No devuelven RunResult, no ejecutan las réplicas siguientes
y no representan el fallo como cancelled ni como advertencia. El mensaje identifica
nodo BPMN, caso, réplica con índice desde cero, instante simulado en segundos y presupuesto.
Los callbacks de streaming pueden haber recibido filas o progreso; esas observaciones
son incompletas y no deben interpretarse como una ejecución exitosa. Los esquemas de
resultados y log no cambian.
1. Estructura general
interface RunResult {
elements: Record<string, ElementMetrics>; // keyed por id BPMN del elemento
flows: Record<string, FlowMetrics>; // keyed por id BPMN del sequence flow
resources: Record<string, ResourceMetrics>; // keyed por id del pool de recursos
process: ProcessMetrics; // agregado único, todo el proceso
bottlenecks: BottleneckEntry[]; // ranking, ver sección 6
replications?: ReplicationSummary; // solo si scenario.run.replications > 1
cancelled?: true; // ausencia = corrida completa
completedReplications?: number; // solo si cancelled = true
warnings: string[]; // ver sección 8
log?: EventLogRow[]; // solo en modo retenido, ver sección 7
}
Todo elemento, flujo o recurso que exista en el IR aparece en el mapa correspondiente aunque su conteo sea cero (por ejemplo, una rama de XOR que nunca se tomó en una corrida corta). El id usado como clave es siempre el id BPMN — nunca el nombre visible (regla del repositorio, ver cabecera de BACKLOG.md).
Con más de una replicación, todos los campos numéricos top-level son la media aritmética del
mismo campo calculado en cada replicación que lo observó. No representan la primera replicación
ni un pool de todos los casos. La mayoría de los campos los observa toda replicación: conteos,
totales, costos, flujos, recursos, integrales de cola. Las estadísticas de tiempo de una tarea o
timer, del proceso y de cada desenlace no se observan en una replicación donde no se completó
ninguna instancia: ahí el valor por replicación es solo la identidad del conjunto vacío
(sección 2), así que queda fuera de la media, y si ninguna replicación lo observa el campo vale 0.
La lista exacta de esos campos condicionales está en la sección 8.
replications.kpis[path].mean coincide con el campo top-level correspondiente en una corrida
completa. Esta decisión se detalla en ADR-024. (prueba: LILA-029, #356)
2. Métricas por elemento (elements[id])
interface ElementMetrics {
started: number;
completed: number;
processing: Stat; // min, max, mean, total
resourceWait: StatSd; // min, max, mean, sd, total
offHoursWait: StatSd; // min, max, mean, sd, total
queueLength: { mean: number; max: number };
fixedCostTotal: number;
}
interface Stat { min: number; max: number; mean: number; total: number }
interface StatSd { min: number; max: number; mean: number; sd: number; total: number }
Definiciones operativas (todas se calculan sobre las instancias del elemento que completaron processing en la replicación, salvo que se indique otra cosa; los casos aún en curso al cortar la corrida no contribuyen a processing/resourceWait/offHoursWait pero sí incrementan started):
started— número de tokens/instancias que llegaron a habilitar el elemento (eventoenabledAtdel event log, sección 7) en la replicación. Para untask, equivale a "Instances/Tokens started" de Bizagi.completed— número de tokens/instancias que terminaron de procesarse en el elemento (eventoendedAt). Un caso en vuelo al momento de parar la corrida cuenta enstartedpero no encompleted(mismo criterio que Bizagi, ver sección 6 del documento de estructura: "Casos en vuelo al parar cuentan como started, no completed").processing.{min,max,mean,total}— estadísticas del tiempo abierto efectivamente trabajado por el elemento, sin espera de recurso ni de calendario. Por R-CAL-8 se obtiene de cada fila comoendedAt − enabledAt − resourceWait − offHoursWait; en ausencia de calendarios se reduce aendedAt − startedAt.mean = total / completed. Fórmula: para el conjuntoPde duraciones de processing completadas,min = min(P),max = max(P),total = Σ P,mean = total / |P|. (prueba: LILA-028)resourceWait.{min,max,mean,sd,total}— estadísticas destartedAt − enabledAt − offHoursWait[enabledAt, startedAt]: tiempo abierto en que la instancia esperó exclusivamente a que un recurso quedara disponible (R-REC-8).sdes la desviación estándar muestral (n−1en el denominador) del mismo conjunto. Si el elemento no requiere recursos (resourcesvacío en el escenario), toda instancia tieneresourceWait = 0— degradación de capacidad infinita (ADR-016 y sección 6). (prueba: LILA-028)offHoursWait.{min,max,mean,sd,total}— estadísticas del tiempo cerrado dentro de todo el intervalo[enabledAt, endedAt], incluido tanto el cierre antes de arrancar como las pausas durante el procesamiento (R-CAL-7). Se acumula por separado deresourceWait; si el elemento usa calendario 24×7 (default sin calendario asignado), siempre vale 0. (prueba de agregación: LILA-028; semántica de calendario: LILA-041)queueLength.{mean,max}— longitud de la cola de instancias esperando el elemento. Cada instancia de actividad (no cada fila: una AND con dos pools aporta una sola vez) contribuye el intervalo semiabierto[enabledAt, startedAt), o[enabledAt, observedUntil)si seguía en cola al cortar.meanes la integral de la longitud instantánea sobre la ventana estadística dividida entre su duración (statisticsDuration, es decir la corrida menos elwarmup, ver sección 8);maxes el máximo instantáneo. Consecuencias de que el intervalo sea semiabierto: una espera de duración cero nunca forma cola (sin recursos,queueLength = {mean: 0, max: 0}para todo elemento, R-DEG-1), y la instancia que sale de la cola en el mismo instante en que otra entra no se cuentan juntas. (prueba: LILA-036)fixedCostTotal—elements[id].fixedCost × completed(costo fijo por token completado, definido en el escenario; verSCENARIO_FORMAT.md). Se obtiene comoΣ row.elementCost, nuncaΣ row.cost(R-COST-4).
El conjunto vacío (#356). Un elemento que no completó ninguna instancia en una replicación
conserva processing, resourceWait y offHoursWait, todos en 0, para que el conjunto de claves
sea el mismo en toda replicación. Ese 0 es la identidad numérica del conjunto vacío, no una
duración observada: total = 0 es cierto (no se procesó nada), pero min, max y mean no
describen ninguna instancia. Del mismo modo, la sd de una sola instancia vale 0 porque no hay
dispersión que medir. Con varias replicaciones ninguno de los dos entra al agregado (sección 8).
Gateways, start, end, terminate y timers de borde cuentan started/completed pero nunca
producen duraciones: sus ceros son exactos y siempre entran.
Bizagi no distingue resourceWait de offHoursWait (ver sección 3: "Espera fuera de horario separada de espera por recurso — Bizagi ✗ / Lila ✓"); es una métrica extra de Lila.
Lifecycle parcial: qué entra en los agregados (decisión de LILA-036)
Las filas terminated, interrupted e inFlight conservan en el log crudo la espera observada hasta
observedUntil (sección 7). En los agregados el criterio es uniforme y no depende del elemento:
- Las estadísticas por instancia —
processing,resourceWait,offHoursWaitde esta sección yprocess.waitTimede la sección 5 — agregan solo instancias constatus = "completed". Una espera cortada porterminateo por el fin de la corrida es una observación censurada: incluirla sesgaría la media a la baja y mezclaría dos poblaciones. Se sigue así la regla que LILA-033 ya aplicaba (filas raw completas, agregado solo de completadas). - Las integrales de estado y los costos —
queueLengthde esta sección,busyTime,utilizationy los costos de la sección 4, yprocess.totalCostde la sección 5 — sí incluyen el lifecycle parcial: miden ocupación realmente observada dentro de la ventana, y R-COST-4 exige que el costo ya incurrido por un caso en vuelo no desaparezca del total.
Consecuencia deliberada y probada: un elemento cuya espera es enteramente censurada tiene
resourceWait.total = 0 y por tanto no aparece en bottlenecks (sección 6), aunque su
queueLength y la utilización de su pool sí lo delaten. (prueba: LILA-036)
3. Métricas por flujo (flows[id])
interface FlowMetrics {
count: number;
}
count— número de tokens que atravesaron el sequence flow en la replicación. Es el "nivel 1" de Bizagi (Process Validation): sirve para verificar qué caminos se activaron y en qué proporción, comparable contra la probabilidad configurada en el escenario (elements[flowId].probability).
4. Métricas por recurso (resources[id])
interface ResourceMetrics {
utilization: number; // fracción >= 0; puede superar 1, ver R-CAL-9
busyTime: number; // segundos
fixedCost: number;
unitCost: number;
totalCost: number;
}
busyTime— segundos-unidad que el pool estuvo ocupado atendiendo instancias, sumados sobre las unidades ocupadas: sicapacity = 3y las tres unidades trabajan simultáneamente 10 s,busyTimeacumula 30 s. Se agrega por fila del event log (ADR-025): cada fila de la cohorte medida conresourceIdystartedAtno nulos aportaresourceQuantity ×el tiempo abierto de[max(startedAt, warmup), min(endedAt ?? observedUntil, t_stop)]según el calendario efectivo de esa actividad —el mismo con el que se calculó suresourceCost, de ahí que la identidad de R-COST-4 siga cuadrando—; sin calendarios ese tiempo abierto es el intervalo entero. Una unidad reservada mientras la tarea está pausada fuera de horario no acumulabusyTime(R-CAL-6). Una fila sentinel, o una que nunca llegó a arrancar, aporta 0. Las filas de casos iniciados antes delwarmupno aportan aunque su ocupación caiga dentro de la ventana: por R-ARR-7 esos casos existen y retrasan a los demás, pero no entran en ninguna integral (ver sección 8).utilization—busyTime / (capacity × horas disponibles según calendario del recurso durante la corrida). Fórmula (ADR-016, única definición que hace comparables un recurso 24×7 y uno con calendario restringido):utilization = busyTime / (capacity × availableTime), dondeavailableTimees el total de segundos que el calendario del recurso estuvo abierto entrerun.starty el fin de la corrida (orun.start + run.duration, lo que aplique). Si el recurso no tiene calendario asignado,availableTimees la duración completa de la corrida (24×7). En nivel 3 (sin calendarios, M2)availableTime = statisticsDuration, es decir la ventana[warmup, t_stop]; los calendarios de M3 solo cambian ese denominador (R-CAL-9). Es una fracción≥ 0, que la CLI imprime como porcentaje (columna BizagiUtilization %): puede superar 1 cuando trabajo iniciado con mayor capacidad continúa después de una bajada sin apropiación. No se aplica clamp;W-UTILIZACION-MAYOR-UNOhace visible el caso. Concapacity × availableTime = 0vale 0, noNaN. La métrica es atribuible a la cohorte posterior alwarmup: trabajo anterior no aportabusyTime, aunque siga ocupando físicamente el pool, y el denominador conserva toda la capacidad de la ventana. Límite conocido (QA de LILA-041, abierto para LILA-044): el denominador mira solo el calendario del pool, así que un pool sin calendario que solo participa en tareas cuyo calendario efectivo sí lo tiene aparece diluido contra el reloj de pared. Enexamples/pedidoel poolhornoocupa el 99,9 % de las horas en que su tarea puede correr y la tabla imprime 27,5 %.fixedCost—resources[id].fixedCost × usos, donde usos esΣ resourceQuantitysobre las filas que llegaron a ocupar el pool (una tarea que ocupa 2 unidades son 2 usos, R-COST-2).unitCost—costPerHour del recurso × (busyTime / 3600)(costo por las horas efectivamente ocupadas).totalCost—fixedCost + unitCost. Identidad verificable contra el log:Σ resources[*].totalCost = Σ row.resourceCostsobre todas las filas de la ventana, es decirΣ fijo × usos + Σ porHora × horas ocupadas(R-COST-4). (prueba: LILA-036)
Todo pool declarado en scenario.resources aparece en el mapa aunque ningún elemento lo use,
con utilization = 0 y busyTime, fixedCost, unitCost y totalCost en cero — es lo mismo que
hace la tabla Resources de Bizagi, que lista todo recurso declarado aunque su utilización sea
0 % (sección 3), y es lo que permite que todas las replicaciones compartan el mismo conjunto de
claves de KPI (sección 8): si un pool ocioso desapareciera del mapa en unas replicaciones y no en
otras, summarizeKpis lo rechazaría con E-KPI-INCONSISTENTE.
Declarar un pool ocioso no es el caso de degradación: R-DEG-1 habla de un escenario sin
sección resources, y solo entonces el mapa queda {} y bottlenecks queda [], exactamente
como en M1 — la degradación no cambia la forma del JSON (prueba LILA-039). Un escenario que
declara pools sin usarlos sí cambia resources, y nada más del resultado.
(prueba: LILA-036, LILA-034)
5. Métricas por proceso (process)
interface ProcessMetrics {
started: number;
completed: number;
inFlight: number;
cycleTime: Percentiles; // min, max, mean, sd, p50, p90, p95
waitTime: Percentiles; // min, max, mean, sd, p50, p90, p95
throughputPerHour: number;
costPerCase: number;
totalCost: number;
}
interface Percentiles {
min: number; max: number; mean: number; sd: number;
p50: number; p90: number; p95: number;
}
started— número de casos que entraron por cualquier start event del proceso.completed— número de casos que llegaron a un end event (o fueron consumidos por unterminate).inFlight—started − completedal momento de cortar la corrida (casos que ni completaron ni fueron descartados).cycleTime.*— estadísticas del tiempo total de vida de un caso (caseEndedAt − caseEnabledAt, sumando todos los elementos por los que pasó);p50/p90/p95son los percentiles 50, 90 y 95 empíricos (interpolación lineal sobre la muestra ordenada) del mismo conjunto de duraciones. Solo se calculan sobre casos completados.simulate(ir, scenario, { onCycleTimes })entrega ese conjunto, por replicación, a quien quiera su distribución (el histograma de Resultados, #460) sin reconstruirlo desde el event log.waitTime.*— mismas estadísticas y percentiles, sobre la suma deresourceWait + offHoursWaitde las actividades que completaron processing en el caso, y solo sobre casos completados. Una filaterminated/interrupted/inFlightno entra en esta métrica aunque su lifecycle raw conserve la espera observada: es la regla de lifecycle parcial fijada por LILA-036 (sección 2). (prueba: LILA-036)throughputPerHour—completed / (duración efectiva de la corrida en horas), donde la duración efectiva excluye elwarmup(ver sección 8).costPerCase— media deΣ row.costsobre los casos completados. Los costos de casos en vuelo sí forman parte detotalCost, pero no de esta media (R-COST-4). (prueba: LILA-028)totalCost— suma defixedCostTotalde todos los elementos mástotalCostde todos los recursos (costo total del escenario en la replicación).
Con varias replicaciones, una replicación sin ningún caso completado tiene los mismos ceros de
conjunto vacío en cycleTime, waitTime, costPerCase y withinServiceLevel, y no entran a su
media (sección 8, #356). Cada desenlace de process.byEndEvent se agrega igual que el resto de
process: su completed sobre todas las replicaciones, y sus estadísticas de ciclo y espera y su
withinServiceLevel solo sobre las replicaciones en que al menos un caso llegó a ese desenlace.
cycleTime.mean "ponderado por las probabilidades de gateways" es justamente lo que produce agregar sobre el conjunto real de casos simulados (no hace falta ponderar aparte): cada camino aparece en la muestra en proporción a cuántas veces se tomó.
6. bottlenecks
interface BottleneckEntry {
elementId: string;
resourceWaitTotal: number; // segundos, = elements[elementId].resourceWait.total
utilization: number; // del recurso principal asignado al elemento, >= 0
}
Ranking de elementos ordenado descendentemente por elements[elementId].resourceWait.total (el elemento donde más tiempo total se perdió esperando recurso). Desempate: mayor utilization primero (del recurso — o, si el elemento usa varios pools, el mayor utilization entre ellos); si también empata, elementId ascendente, para que el orden sea total y determinista. Elementos con resourceWait.total = 0 no aparecen en el ranking. Es una métrica que Bizagi no ofrece (sección 3: "Ranking de cuellos de botella — Bizagi ✗ / Lila ✓").
Con varias replicaciones, resourceWaitTotal es la media incondicional: una réplica donde el
elemento no espera aporta 0, igual que en elements[id].resourceWait.total. utilization es la
media condicional sobre las réplicas donde el elemento sí aparece en bottlenecks; su ausencia no
es una observación de utilización cero y no reduce artificialmente el valor publicado. Promediar
así no reordena el ranking, que lo decide resourceWaitTotal: utilization solo desempata.
(prueba: LILA-201, sobre examples/pedido)
Los pools de un elemento se leen del event log (resourceId de sus filas), no del escenario:
cada fila ya trae el pool efectivamente asignado, así que el ranking vale igual para un solo pool,
para selection: "and" y para la selección "or" de LILA-035 sin ningún caso especial.
(prueba: LILA-036)
7. Event log
Filas planas, una por asignación de pool por instancia de actividad. Las filas de una misma
ocurrencia se agrupan por activityInstanceId; una actividad sin recurso conserva una fila
sentinel. Se emiten por streaming
(opts.onEvent) para que la CLI escriba a CSV y la web pueda agregar/muestrear sin cargar todo en
memoria (ver sección 6 del documento de estructura: hasta 6 M filas en corridas grandes).
opts.log vale true por defecto; log: false suprime el callback incluso si se proporcionó
onEvent, pero no cambia ninguna métrica. (prueba: LILA-029)
Quién se queda con las filas: los tres modos de result.log
simulate nunca entrega las mismas filas dos veces. Qué contiene result.log depende solo de las
opciones, y en ningún caso cambia una métrica ni el orden de las filas (prueba: LILA-037):
| Opciones | onEvent |
result.log |
|---|---|---|
ninguna (log ausente) — modo retenido |
no se llama | el log completo de la corrida, en orden de simulación y de replicación, incluidas las filas anteriores al warmup y las de la réplica parcial de una corrida cancelada |
onEvent presente — modo streaming |
una llamada por fila | ausente: el consumidor ya las recibió y retenerlas duplicaría hasta 6 M de objetos |
log: false — desactivado |
no se llama, aunque se haya pasado | ausente |
lila run usa siempre uno de los dos modos sin retención: con --csv pasa onEvent y escribe
cada fila a log.csv según llega; sin --csv pasa log: false, de modo que el RunResult de
--json nunca engorda con el event log. Solo el modo retenido acota su memoria por el tamaño del
log; los otros dos la acotan por el pico de una replicación.
| Columna | Tipo | Unidad | Definición |
|---|---|---|---|
replication |
integer | — | Índice de la replicación, 0..scenario.run.replications-1. |
caseId |
string | — | Identificador del caso (instancia de proceso), único dentro de la replicación. |
activityInstanceId |
string | — | Identificador opaco y único de la ocurrencia de tarea/timer dentro de la replicación, derivado de un contador; agrupa sus asignaciones. |
elementId |
string | — | id BPMN del elemento (nunca el nombre). |
resourceId |
string | null | — | id del pool efectivamente asignado; null en la sentinel de una actividad sin recurso o que seguía esperando. |
allocationIndex |
integer | null | — | Posición de la asignación en el array resources del elemento; null para sentinel. |
resourceQuantity |
integer | null | unidades | Cantidad ocupada del pool; null para el sentinel. |
status |
"completed" | "terminated" | "interrupted" | "inFlight" |
— | Razón de cierre observable: final normal, terminate BPMN, un temporizador de borde interruptor (SEMANTICS.md R-BND-5), o parada/cancelación. startedAt = null distingue la espera no asignada. |
enabledAt |
number | segundos desde run.start |
Instante en que el token llegó al elemento y quedó habilitado para empezar. |
startedAt |
number | null | segundos desde run.start |
Instante en que empezó a procesarse; null si la actividad se cerró todavía en cola. Nunca es posterior a observedUntil: una concesión que cae en tiempo cerrado apunta a la siguiente apertura, y si esa apertura queda más allá del corte de la corrida la fila informa el corte, no un futuro. |
endedAt |
number | null | segundos desde run.start |
Instante en que terminó normalmente; solo existe con status = "completed". |
observedUntil |
number | segundos desde run.start |
endedAt al completar; instante de terminate, cancelación o parada para lifecycle parcial. |
resourceWait |
number | segundos | Porción de la espera atribuible a falta de recurso; si startedAt = null, se observa hasta observedUntil. No incluye tiempo de calendario cerrado. |
offHoursWait |
number | segundos | Tiempo cerrado en [enabledAt, endedAt ?? observedUntil]. Para completadas cumple endedAt − enabledAt = resourceWait + offHoursWait + processing (R-CAL-7/8). (prueba de la identidad: LILA-028) |
elementCost |
number | run.currency |
Fijo del elemento, cargado una sola vez al completar: primera asignación según el escenario o sentinel; 0 en filas adicionales/parciales. |
resourceCost |
number | run.currency |
Fijo y costo por tiempo ocupado de esta asignación; 0 si nunca arrancó. |
cost |
number | run.currency |
Identidad exacta elementCost + resourceCost. |
Una tarea con dos pools AND ya iniciada produce dos filas con el mismo activityInstanceId; si
sigue esperando al corte produce una sentinel, no requisitos ficticios. No existe una fila con
resources[]. Esto conserva CSV plano y permite reconstruir ocupación/costos. fixedCostTotal se
obtiene de Σ elementCost: el fijo vive solo en la fila canónica de menor allocationIndex
efectivamente emitida. process.totalCost = Σ cost; sumar cost para el fijo del
elemento duplicaría recursos y está prohibido. (decisión: ADR-025; prueba: LILA-033, LILA-034,
LILA-036, LILA-037)
started, completed, processing, resourceWait, offHoursWait, waitTime y el intervalo de
cola que alimenta queueLength se agregan una vez por (replication, activityInstanceId); costos
y ocupación de pool se agregan por fila.
log.csv
log.csv lleva las 17 columnas de la tabla anterior, en ese mismo orden y con los mismos
nombres internos (Bizagi no publica un event log, así que aquí no hay nombres de columna que
replicar; ver sección 10). Reducir el conjunto rompería a los consumidores v1 (ADR-025).
Al exportar (CSV de la CLI, toCsv()), enabledAt/startedAt/endedAt se derivan además a
timestamps ISO 8601 absolutos (run.start + segundos) en tres columnas añadidas al final,
enabledAtIso/startedAtIso/endedAtIso; el CSV en bruto para procesamiento programático
conserva los segundos relativos, que siguen siendo los valores autoritativos porque el ISO se
redondea al milisegundo más cercano. Una columna de tiempo nula (el startedAt de una fila que nunca arrancó)
deja también su celda ISO vacía, y un run.start ilegible vacía las tres en lugar de abortar el
archivo. Sin run.start el CSV se queda en las 17 columnas.
lila run --csv escribe log.csv en streaming, fila a fila desde opts.onEvent, con un búfer
de 1 MiB y publicación atómica por rename: el archivo completo nunca está en memoria y el
RunResult no retiene el log (modo streaming, arriba). (prueba: LILA-037)
El event log, con un mapeo trivial de columnas, es compatible con XES (IEEE 1849) y OCEL 2.0 (ver sección 6 del documento de estructura).
8. replications / ci95 y warmup
Cuando scenario.run.replications > 1, cada KPI numérico de interés (los de process, y opcionalmente los de elements/resources que la CLI decida imprimir) se resume además entre replicaciones:
interface ReplicationSummary {
count: number; // replicaciones completas resumidas; >= 2
kpis: Record<string, { // keyed por nombre de KPI, p. ej. "process.cycleTime.mean"
mean: number;
n: number; // replicaciones que observaron el KPI; <= count
sd?: number; // solo con n >= 2
ci95?: [number, number]; // límites del intervalo de confianza al 95 %; solo con n >= 2
}>;
}
Los segmentos dinámicos de esos nombres (ids BPMN de elementos, flujos y recursos) escapan
. como \. antes de formar el path. Así, por ejemplo, el KPI processing.mean del elemento
Task.A se llama elements.Task\.A.processing.mean, sin colisionar con otros ids válidos.
Los ids sin punto conservan exactamente los nombres mostrados arriba. (prueba: LILA-027)
n(#356) — número de replicaciones que observaron el KPI, una observación por replicación. En un KPI incondicional vale siemprecount; en uno condicional (lista de abajo) puede ser menor. No es un número de casos:elements[id].completedcuenta instancias,ncuenta replicaciones.mean/sd— media y desviación estándar muestral del KPI a través de esasnreplicaciones (una observación por replicación, no por caso). La media es la de los valores por replicación, nunca la de los casos agrupados (ADR-024).ci95— intervalo de confianza al 95 % para la media,mean ± t(n-1, 0.975) × sd / √n(t de Student conn-1grados de libertad; conngrande se aproxima a1.96 × sd/√n). Es la métrica que Bizagi solo ofrece desde What-If (sección 3: "Replicaciones — Bizagi ✓ solo en what-if / Lila ✓ siempre, con IC 95 %"); en Lila se calcula siempre quereplications > 1y el KPI tenga al menos dos observaciones.- Elegibilidad — con
n ≥ 2la entrada es{mean, n, sd, ci95}; conn = 1es{mean, n}, la única observación, sin dispersión ni intervalo; conn = 0es{mean: 0, n: 0}, donde 0 es el valor identidad que conserva el conjunto de claves, no una estimación. Las claves aparecen siempre en ese orden.
KPI condicionales. Una replicación observa estos paths solo cuando se cumple la condición; si
no, su valor es la identidad del conjunto vacío (secciones 2 y 5) y no entra al n del path:
| Paths | Se observa cuando |
|---|---|
elements.X.processing.{min,max,mean}, elements.X.resourceWait.{min,max,mean}, elements.X.offHoursWait.{min,max,mean} |
elements.X.completed > 0, en un task o un timer que no es de borde |
elements.X.resourceWait.sd, elements.X.offHoursWait.sd |
elements.X.completed ≥ 2, en un task o un timer que no es de borde |
process.cycleTime.* y process.waitTime.* salvo sd, process.costPerCase, process.withinServiceLevel |
process.completed > 0 |
process.cycleTime.sd, process.waitTime.sd |
process.completed ≥ 2 |
process.byEndEvent.E.cycleTime.* y .waitTime.* salvo sd, process.byEndEvent.E.withinServiceLevel |
process.byEndEvent.E.completed > 0 |
process.byEndEvent.E.cycleTime.sd, .waitTime.sd |
process.byEndEvent.E.completed ≥ 2 |
Todo otro path es incondicional y tiene n = count: started, completed, todo *.total,
queueLength.*, fixedCostTotal, flows.*, resources.*, throughputPerHour,
process.totalCost, inFlight, byEndEvent.E.completed, y toda estadística de un gateway, un
start, un end, un terminate o un timer de borde, cuyos ceros son exactos. La regla de la sd es el
mismo defecto un nivel más abajo: dentro de una replicación, la desviación muestral de una sola
observación vale 0 por convención, no por una ausencia de dispersión medida. bottlenecks conserva
su propia regla (sección 6).
Cuando una tarea o temporizador, el proceso o un desenlace lo observan algunas replicaciones
pero no todas, la corrida lleva un W-REPLICACIONES-SIN-OBSERVACIONES por cada sujeto (sección
9), para que la media no se lea como la de toda la corrida. El aviso se decide sobre el path
mean del sujeto; un path sd puede tener un n menor (necesita dos observaciones) sin aviso
propio: léase n junto al valor.
Los resultados guardados antes de 1.0.0-beta.1 no traen
ny se calcularon con la definición anterior: las replicaciones sin observaciones contaban como cero. Susdy suci95están siempre presentes. Los lectores los aceptan tal cual (result.schema.ts); no se recalculan.
Si opts.signal.aborted detiene la corrida, el resultado lleva cancelled: true y
completedReplications, que cuenta exclusivamente replicaciones terminadas; la ausencia de
cancelled significa corrida completa. El top-level conserva el trabajo procesado: promedia las
replicaciones completas y la parcial si existe. replications, cuando puede calcularse con al
menos dos replicaciones completas, excluye siempre la parcial; con menos de dos completas se omite
para no publicar una desviación o un IC inválidos. Una cancelación entre replicaciones no agrega
una réplica parcial ficticia. (prueba: LILA-029)
opts.onProgress, cuando existe, recibe primero fraction = 0, aun si la primera réplica no
tiene eventos. La fracción es estrictamente monótona y una corrida completa termina en 1; una
cancelada puede terminar antes. No se instala ningún hook por evento cuando el callback está
ausente. (prueba: LILA-029)
warmup: scenario.run.warmup (segundos desde run.start) excluye de todas las estadísticas los casos que se iniciaron antes de que terminara el warmup. Esos casos siguen ocupando pools, haciendo cola y alterando cuándo arrancan los casos medidos; sus filas igual se emiten, pero la cohorte queda fuera de process, elements, resources, costos y de las integrales de queueLength/utilización. Las integrales de cohortes medidas se recortan además a [warmup, t_stop]. throughputPerHour usa como denominador la duración efectiva de la corrida excluyendo el propio warmup. (prueba: LILA-027, LILA-033, LILA-036)
9. warnings[]
Lista de strings, una por condición no fatal detectada durante resolveScenario, validate o simulate que el usuario debe poder ver sin que la corrida se detenga. Ejemplos (no exhaustivo, ver SEMANTICS.md para la lista completa de reglas que generan warnings):
- Probabilidades de un XOR/OR que no suman 1 y se normalizaron.
- Una clave de
elementsen el escenario que no corresponde a ningún id del IR (sobra, no falta — una clave que falta es error, no warning). - Una referencia
lila:*Refcolgante hacia el catálogo (verBPMN_EXTENSION.md). - Uso de una distribución
normal/truncatedNormalcon probabilidad de muestrear un valor negativo mayor a 1 % (se trunca a 0, pero se avisa). - Utilización mayor que 1 por trabajo que continúa tras una bajada de capacidad sin apropiación (
W-UTILIZACION-MAYOR-UNO); el valor no se trunca. - Un pool cuya cola crece sin estabilizarse: llega más trabajo del que puede despachar y no hay
estado estacionario (
W-RECURSO-SATURADO: <poolId>: la cola crece sin estabilizarse (λ/μ·c ≈ X), uno por pool y por corrida). El aviso no cambia ninguna métrica; avisa de que elresourceWait.totalde las tareas de ese pool y su puesto enbottlenecks(sección 6) crecen con la duración de la corrida y no son comparables con los de un pool estable.Xes el ρ estimado del propio log: la demanda atribuida al pool —solo las esperas en que él estuvo lleno, no las que comparte por AND o por OR— entre las unidades que concedió, promediado entre replicaciones. Un pool que se autoestrangula frena su propia demanda atribuida, así que unaresources[poolId].utilizationmedia ≥ 0,9 es la segunda puerta al mismo aviso; entonces imprimeW-RECURSO-SATURADO: <poolId>: la cola crece sin estabilizarse (ocupación ≈ Y %)en su lugar, con la ocupación en porcentaje entero. El criterio completo está enSEMANTICS.md§ 17.λ/μ·ces la notación de colas para la forma de la razón, no una afirmación sobre la demanda declarada del escenario: el número se mide sobre el horizonte simulado, así que la app web imprime al lado del aviso una nota que dice el criterio de arriba y qué es el número que se ve —esa razón, o la ocupación cuando el aviso sale por la segunda puerta— (#357). (LILA-191, #320) - Una tarea o timer, el proceso o un desenlace que algunas replicaciones observaron y otras no
(
W-REPLICACIONES-SIN-OBSERVACIONES, sección 8): por ejemploW-REPLICACIONES-SIN-OBSERVACIONES: Task: ninguna instancia se completó en 12 de 30 replicaciones; sus estadísticas de tiempo promedian solo las otras 18.Sale una vez por sujeto y por corrida, solo cuando0 < n < Rsobre las replicaciones que incluye el top-level, en el orden de los nodos del IR, después el proceso y después los desenlaces. No cambia ninguna métrica; dice que las estadísticas de tiempo publicadas describen las replicaciones donde el sujeto ocurrió, no todas. Textos exactos enSEMANTICS.md§ 17. (#356)
10. Mapeo de nombres de columna: interno → Bizagi
La CLI (lila run) imprime las tablas de elements y resources con los nombres de columna de Bizagi, verificados contra la ayuda oficial (help.bizagi.com, niveles 1–4 y simulation_in_bizagi.htm; ver investigacion-2026-09-03/02-bizagi-simulacion.md), para que un usuario que migra desde Bizagi pueda comparar números sin traducir columnas. Bizagi usa indistintamente "Tokens" e "Instances" según la página de ayuda; se documentan ambas variantes observadas.
Un solo mapa de nombres (LILA-201). Las tablas de abajo viven en el código una sola vez, en
COLUMN_LABELS (packages/engine/src/format.ts), con la clave ${ámbito}:${ruta de la métrica}
—la misma partición scope/metric que produce compare() (sección 11)—. Lo consumen las cuatro
superficies que enseñan resultados y ninguna redefine un nombre por su cuenta:
| Superficie | Nombre de columna | Unidad de las duraciones |
|---|---|---|
lila run (tablas de consola) |
columnHeader(scope, metric, unit) |
baseTimeUnit, con sufijo (min) / (h) … |
lila compare (columna Metric) |
columnLabel(scope, metric) |
la fila lleva la unidad; el rótulo no |
CSV de csv.ts (elements.csv, flows.csv, resources.csv, process.csv) |
columnLabel(scope, metric) |
segundos (sección 1), por eso el rótulo va desnudo: Busy time, no Busy time (min) |
ResultsView de la web |
columnLabel(...) + (unidad) en duraciones |
baseTimeUnit, igual que la CLI |
Es decir: el nombre es único y el sufijo de unidad lo añade solo quien convierte a baseTimeUnit.
Una métrica sin nombre en el mapa (queueLength.mean, offHoursWait.*) conserva su ruta interna
en vez de recibir un nombre inventado. (prueba: LILA-201)
Tabla "Process elements" (niveles 1–4)
Campo interno (RunResult.elements[id]) |
Nombre de columna Bizagi |
|---|---|
started |
Instances started (también "Tokens started") |
completed |
Instances completed (también "Tokens completed") |
processing.min |
Minimum time |
processing.max |
Maximum time |
processing.mean |
Average time |
processing.total |
Total time |
resourceWait.min |
Minimum time (waiting for resource) |
resourceWait.max |
Maximum time (waiting for resource) |
resourceWait.mean |
Average time (waiting for resource) |
resourceWait.sd |
Standard deviation (waiting for resource) |
resourceWait.total |
Total time (waiting for resource) |
fixedCostTotal |
Total fixed cost |
La consola de lila run, elements.csv y la pestaña "Elementos del proceso" de la web imprimen
esta tabla completa, en este orden y con las columnas Id, Name y Type delante
(LILA-201: hasta entonces la consola se quedaba en Total time y perdía el grupo "waiting for
resource" y el costo fijo, que el CSV y la web sí traían). lila compare y la vista de
comparación no son tablas de resultados sino de KPI, y muestran su subconjunto curado (sección 11)
con estos mismos nombres.
offHoursWait, queueLength, resources[id].busyTime, los percentiles de process.cycleTime/process.waitTime, throughputPerHour, costPerCase, process.totalCost y bottlenecks no tienen columna equivalente en Bizagi — son las métricas extra listadas en la sección 3 del documento de estructura ("Extras que Bizagi no da"); la CLI las imprime en tablas adicionales sin intentar nombrarlas "a la Bizagi", pero sus nombres viven en el mismo mapa para que consola, CSV y web no los escriban distinto.
Tabla "Resources" (niveles 3–4)
Campo interno (RunResult.resources[id]) |
Nombre de columna Bizagi |
|---|---|
utilization |
Utilization (%) |
busyTime |
— (sin columna en Bizagi; extra de Lila, en segundos-unidad) |
fixedCost |
Fixed cost |
unitCost |
Unit cost |
totalCost |
Total cost |
La tabla lista una fila por pool declarado, también los que quedaron con 0 % de utilización (sección 4), igual que Bizagi.
Tabla "Sequence flows" (nivel 1)
Campo interno (RunResult.flows[id]) |
Nombre de columna Bizagi |
|---|---|
count |
Instances/Tokens completed (para el sequence flow) |
Tabla "Process summary" / "Proceso" (extras de Lila)
Bizagi no publica esta tabla; los nombres son de Lila y salen del mismo mapa.
Campo interno (RunResult.process) |
Nombre de columna |
|---|---|
started |
Instances started |
completed |
Instances completed |
inFlight |
In flight |
cycleTime.min / .max / .mean / .sd |
Cycle time minimum / maximum / average / standard deviation |
cycleTime.p50 / .p90 / .p95 |
Cycle time p50 / p90 / p95 |
waitTime.min / .max / .mean / .sd |
Wait time minimum / maximum / average / standard deviation |
waitTime.p50 / .p90 / .p95 |
Wait time p50 / p90 / p95 |
throughputPerHour |
Throughput per hour |
costPerCase |
Cost per case |
totalCost |
Total cost |
process.csv y la pestaña "Proceso" de la web llevan las 20 columnas. La consola de lila run
imprime un subconjunto —started, completed, inFlight, la media y los percentiles p50/p90/p95
de cycleTime y de waitTime, throughputPerHour, costPerCase y totalCost— porque las 20 no
caben legibles en una fila de terminal; los mínimos, máximos y desviaciones siguen íntegros en
--json y en el CSV. (LILA-201: antes la consola llamaba Average cycle, p50 y
Throughput/hour a lo que el CSV y la web ya llamaban Cycle time average, Cycle time p50 y
Throughput per hour, y no imprimía ni totalCost ni la espera.)
Tabla "Cuellos de botella"
Ranking de la sección 6, sin equivalente en Bizagi. Columnas: Id, Name,
Total time (waiting for resource) (= elements:resourceWait.total) y Utilization (%)
(= resources:utilization).
Se imprime siempre, también cuando el escenario no declara ni un pool: sin recursos el ranking
está vacío por construcción (R-DEG-1) y la superficie lo dice con "Sin espera por recurso
detectada." en vez de callar la sección. Así la consola y la tarjeta de ResultsView en la web
enseñan lo mismo ante el mismo RunResult. (decisión: LILA-201; prueba: cli-run.test.ts,
ResultsView.test.tsx)
Nota de confianza: los nombres exactos arriba están marcados [verified] en la investigación citada salvo el desglose de "waiting for resource" en columnas separadas Min/Max/Avg/Std.Dev/Total, que la ayuda de Bizagi describe como grupo pero sin dar el texto literal de cada subcolumna — se usa el patrón Minimum/Maximum/Average/Standard deviation/Total time por consistencia con el grupo de processing. Si al reproducir el ejemplo oficial de nivel 3/4 de Bizagi (prueba de aceptación de M1, sección 7 del documento de estructura) el texto real difiere, este documento se corrige entonces sin abrir un ticket aparte.
11. compare(results[]) (LILA-038)
compare (packages/engine/src/core/compare.ts) pone varios RunResult lado a lado para leer un
what-if. Es una función pura de core/: no imprime nada — la tabla de consola es lila compare
(LILA-047) y la vista de la web LILA-064.
function compare(results: readonly RunResult[]): CompareResult;
interface CompareResult {
count: number; // resultados comparados; el índice 0 de cada array es la base
rows: CompareRow[];
}
interface CompareRow {
kpi: string; // path idéntico al de replications.kpis (sección 8)
scope: 'elements' | 'flows' | 'resources' | 'process';
id: string | null; // id BPMN sin escapar; null cuando scope = "process"
metric: string; // p. ej. "resourceWait.mean", "cycleTime.p95"
base: number | null;
values: (number | null)[]; // values[0] === base
deltaAbs: (number | null)[]; // values[i] − base
deltaRel: (number | null)[]; // (values[i] − base) / base
significant: boolean[]; // significant[0] siempre false
}
- Base:
results[0]. Todo delta se mide contra ella, nunca contra la columna anterior. - Filas: una por KPI numérico escalar, los mismos que aplana
numericKpis(secciones 2–5), con el mismo escape de.en los ids dinámicos (sección 8).scope/id/metricson ese path ya partido, para que la CLI agrupe por elemento, recurso o proceso sin volver a implementar el escape. - Orden: los KPI del resultado base en su orden de aparición (elementos, flujos, recursos,
proceso) y, detrás, los que solo existen en resultados posteriores —un pool nuevo en el TO-BE—,
en orden de resultado. Dos llamadas con la misma entrada producen
JSON.stringifyidéntico. - Claves ausentes: un KPI que no existe en algún resultado vale
nullahí; no es un error, y sudeltaAbs/deltaReltambién sonnull. deltaRelcon base 0:null, nuncaInfinityniNaN, para que el JSON siga siendo válido.- Lista vacía:
compare([])lanzaRangeErrorconE-COMPARE-VACIO. Un solo resultado sí es válido: da deltas 0 y ninguna significancia. - Significancia:
significant[i]estruecuando los intervalosci95de la base y del resultadoi(sección 8) no se solapan. Dos intervalos que solo se tocan en un extremo cuentan como solapados. Un resultado sinreplications—una sola replicación, o una corrida cancelada con menos de dos completas— no tiene IC:significantvalefalse, y los deltas siguen siendo válidos, solo que sin respaldo estadístico. Lo mismo vale para un KPI suelto cuyo resumen no traeci95porque lo observaron menos de dos replicaciones (n < 2, sección 8): nunca es significativo, esté del lado que esté. (#356) - Los deltas se leen limpios porque R-DET-3 garantiza números aleatorios comunes: cambiar la capacidad de un pool no altera el stream de los elementos que no se tocaron.
Aceptación (examples/pedido, seed: 42, 30 replicaciones): pasar de 2 a 3 cajeros marca
elements.Task_TomarPedido.resourceWait.mean como significativa (14,94 s → 2,19 s, IC
[14,70; 15,19] y [2,12; 2,27], disjuntos) y no marca elements.Task_Preparar.resourceWait.mean,
cuyo cuello de botella es el pool horno con capacity 1, que el TO-BE no toca.
(prueba: LILA-038)
Los cuatro números de arriba son los del ejemplo sin calendarios, que es como corría el motor hasta M2. Desde LILA-041
examples/pedidosimula su calendariooficinay la misma aceptación da 14,97 s → 2,18 s, IC [14,57; 15,37] y [2,09; 2,27]: sigue siendo significativa yTask_Prepararsigue sin serlo. La conclusión no cambia; los decimales sí. (prueba: LILA-041, QA)
12. Exportación XLSX (issue #80)
lila run --xlsx libro.xlsx, lila compare --xlsx libro.xlsx y los botones "Exportar XLSX" de la
app web escriben una hoja de cálculo con los mismos números que el CSV. Es un OOXML escrito a mano
sobre fflate (packages/engine/src/xlsx.ts): cadenas inline, celdas numéricas, sin tabla de
cadenas compartidas y con tres formatos de número, que es el subconjunto que leen Excel,
LibreOffice, Numbers, pandas y openpyxl. Los bytes son deterministas —las entradas del zip llevan
una marca de tiempo fija—, así que dos exportaciones de la misma corrida son idénticas.
Las tablas salen de los mismos constructores de filas que el CSV (elementsRows, flowsRows,
resourcesRows, processRows en packages/engine/src/csv.ts), de modo que las dos exportaciones
no pueden divergir; los nombres de columna son el mapa de la sección 10, sin traducir. Solo los
nombres de las hojas y las etiquetas de las hojas que el libro añade salen del catálogo de mensajes
(Summary/Resumen, …).
run --xlsx: cinco hojas
| Hoja | Columnas | Contenido |
|---|---|---|
Resumen |
Sección, Id, Name, Metric, Valor | las métricas de process de la sección 5, una por fila; los casos completados por evento de fin cuando existe process.byEndEvent; por cada pool declarado, Capacidad, Horas laborables y Costo de nómina, más el total; y un bloque Notas (#358, #359) |
Elementos |
las de elements.csv |
filas idénticas a elements.csv |
Flujos |
las de flows.csv |
filas idénticas a flows.csv |
Recursos |
las de resources.csv |
filas idénticas a resources.csv |
Parámetros |
Sección, Id, Name, Parámetro, Valor | el escenario resuelto que corrió: run (start, duration, warmup, replications, seed, unidad de tiempo, moneda), calendarios, recursos, llegadas, tiempos de tarea y probabilidades de compuerta |
Costo de nómina es capacity × costPerHour × las horas abiertas de la corrida según el
calendario del pool: lo que cuesta la plantilla esté ocupada o no. No es
resources[id].unitCost (sección 4), que solo cobra las horas realmente ocupadas; el libro lleva
las dos lecturas. Sin run.duration las horas no se conocen y esas celdas quedan vacías.
El event log no es una hoja: una corrida de millones de filas supera las 1 048 576 filas que
admite una hoja. --csv lo sigue escribiendo en streaming (sección 7).
Unidades, formatos y el bloque Notas (#358, #359)
El libro nunca convierte: toda duración que lleva está en segundos (sección 1), cualquiera sea
el run.baseTimeUnit que declare el escenario y cualquiera sea lo que muestre la app en pantalla.
Donde eso se puede decir al lado del número, se dice:
- las etiquetas de
MetricdeResumenllevan la unidad con la misma convencióncolumnHeader(scope, metric, unit)que usa la consola —Cycle time average (s), y nada en un conteo, una tasa o dinero—; - las cabeceras de
Elementos,FlujosyRecursosno, y no van a llevarla: son los nombres de columna de la sección 10 y están fijadas a las del CSV, por este documento y porpackages/engine/test/xlsx.test.ts. Su unidad se declara en el bloqueNotas.
El bloque Notas de Resumen es la última sección de la hoja, una fila por nota y el texto en la
columna Valor. No añade ninguna métrica ni cambia ningún valor; responde a las tres lecturas que
un libro abierto lejos de la app entiende mal: la unidad de las hojas fuente, que el Cost per case es el costo medio de los casos que terminaron y no Total cost / Instances completed
(sección 5), y que el Costo de nómina cobra la disponibilidad mientras el Unit cost cobra las
horas realmente ocupadas (sección 4).
Los formatos de número son solo presentación —la celda conserva el doble completo—:
| Formato | Dónde |
|---|---|
0.### |
toda celda numérica de las cinco hojas y de Comparación que no sea una de las fracciones de abajo, para que una media imprima 42.857 y no 42.857142857142854 |
0.00% |
las fracciones: el delta relativo de Comparación y las filas Within service level de Resumen (0.153 se ve como 15.30%). Son el único formato por fila del libro, porque la columna Valor de Resumen lleva además conteos, segundos y dinero; con 0.### un nivel de servicio de 0.001004 se vería 0.001 y cualquiera por debajo de 0.0005 como el 0 que la sección 5 prohíbe leer como «0 % cumplido» |
| general | la fila de cabecera, los textos de Notas y toda otra celda de texto o booleana |
Utilization (%) no es una celda de porcentaje: su valor ya viene multiplicado por 100 y el nombre
de la columna lo dice, igual que en el CSV y en la consola.
compare --xlsx: una hoja por escenario más Comparación
Cada escenario tiene su propia hoja Resumen, con el nombre del escenario (saneado, recortado a 31
caracteres y hecho único). La hoja Comparación lleva una fila por KPI de compare() (sección 11)
con Kpi, Scope, Id, Name, Metric y, por escenario, su valor y los dos extremos de su
IC 95 %; cada escenario no base añade el delta absoluto, el delta relativo y si su intervalo
se solapa con el de la base — un false ahí es lo que la CLI imprime como *. Sin
replicaciones no hay intervalo y la celda queda vacía, nunca en false.