wandres.dev
SCXML Y EL ESTÁNDAR · la especificación

Resolución de conflictos: qué transición gana

Ante un evento, varias transiciones pueden estar habilitadas a la vez: la del hijo y la del padre, dos hermanas del mismo estado, o una en cada región paralela. SCXML resuelve esta indeterminación con dos reglas encadenadas que no dejan margen. Primero se selecciona ascendiendo desde cada estado atómico hacia sus ancestros, de modo que el estado más profundo elige antes; después se eliminan las transiciones cuyos conjuntos de salida se solapan, dando la victoria a la de origen más descendiente. El resultado es el conjunto óptimo de transiciones, y su cálculo es la razón por la que el hijo siempre desaloja al padre.

⏱ 20 min

Una máquina de estado determinista no es una máquina en la que solo una transición puede dispararse: es una máquina en la que, cuando varias podrían dispararse, existe una regla que decide cuál lo hace, y esa regla da siempre el mismo resultado. La diferencia importa porque los statecharts jerárquicos con regiones ortogonales generan conflictos constantemente y por diseño. Un evento CANCELAR puede estar declarado en un estado hoja, en su padre y en la raíz al mismo tiempo, y las tres declaraciones son legítimas. Lo que hace SCXML no es prohibir esa ambigüedad sino resolverla mediante dos mecanismos encadenados: un orden de selección que recorre el árbol de dentro hacia fuera, y un criterio de preempción basado en qué estados abandona cada candidata. Comprender esos dos mecanismos convierte una fuente crónica de sorpresas en un sistema de prioridades que puedes usar a favor.

🎯 Al terminar esta lección sabrás
  • Definir cuándo dos transiciones entran en conflicto mediante sus conjuntos de salida.
  • Reconstruir el orden de selección: por estados atómicos y ascendiendo por ancestros.
  • Aplicar la regla de preempción que da prioridad al origen más profundo.
  • Distinguir el conflicto real del aparente en regiones paralelas.

Cuándo dos transiciones colisionan de verdad

La definición formal de conflicto es sorprendentemente concreta y no menciona ni el evento ni la guarda: dos transiciones están en conflicto si sus conjuntos de salida se intersecan, es decir, si ambas pretenden abandonar al menos un estado en común. Todo lo demás es coincidencia inofensiva.

Esa definición tiene una consecuencia inmediata y liberadora. Dos transiciones que responden al mismo evento en dos regiones paralelas distintas no están en conflicto, porque cada una abandona estados de su propia región. Pueden y deben ejecutarse las dos en el mismo microstep. En cambio, una transición de un hijo y otra de su padre que responden al mismo evento sí colisionan casi siempre, porque el conjunto de salida del padre incluye a todos sus descendientes activos, y entre ellos está el hijo.

stateDiagram-v2
state Sesion {
  [*] --> Panel
  state Panel {
    [*] --> Lista
    Lista --> Detalle : ABRIR
    Detalle --> Lista : ESC
  }
  Panel --> Ajustes : ESC
}
Sesion --> Bloqueada : ESC

En ese diagrama el evento ESC está declarado en tres niveles. Si la configuración activa incluye Detalle, las tres transiciones están habilitadas y las tres colisionan: la de Detalle sale de Detalle, la de Panel sale de Panel y por tanto de Detalle, y la de Sesion sale de todo. El algoritmo debe elegir una, y elige siempre la misma.

Nótese que la colisión depende de la configuración y no solo del documento. Las mismas tres transiciones no colisionan si la máquina está en Ajustes: allí la de Detalle ni siquiera es candidata porque su origen no está activo, y las otras dos siguen chocando entre sí pero con un ganador distinto. Un conflicto no es una propiedad estática del diagrama sino una situación que se evalúa en cada evento, y por eso la misma máquina puede responder a ESC de tres maneras según dónde esté.

ℹ️
El conflicto se mide en estados, no en eventos

Es tentador pensar que dos transiciones chocan porque escuchan el mismo evento. No es así: chocan porque quieren desalojar el mismo estado. Dos transiciones con eventos distintos jamás se evalúan a la vez, y dos transiciones con el mismo evento pueden convivir sin problema si sus dominios son disjuntos. El criterio operativo es el conjunto de salida, y ese conjunto se deriva del dominio de la transición, que a su vez es el ancestro compuesto común más bajo entre el origen y los destinos.

La selección: de las hojas hacia la raíz

Antes de resolver conflictos hay que reunir candidatas, y el modo de reunirlas ya introduce una prioridad. El algoritmo no recorre todas las transiciones del documento: recorre los estados atómicos de la configuración actual en orden documental y, para cada uno, asciende por la cadena de ancestros. En cada estado visitado examina sus transiciones en orden documental y se queda con la primera cuyo evento coincida y cuya guarda se cumpla. En cuanto encuentra una, deja de ascender para ese estado atómico.

// La forma de la seleccion, simplificada al esqueleto normativo.
function selectTransitions(evento: Evento): Transicion[] {
  const habilitadas: Transicion[] = []
  for (const atomico of estadosAtomicosEnOrdenDocumental()) {
    // se asciende hasta la raiz o hasta encontrar una candidata
    for (const estado of [atomico, ...ancestrosDe(atomico)]) {
      const t = estado.transiciones.find(
        (t) => coincideEvento(t, evento) && guardaSeCumple(t),
      )
      if (t) { habilitadas.push(t); break }
    }
  }
  return removeConflictingTransitions(habilitadas)
}

De aquí salen las dos reglas prácticas que gobiernan tu día a día. La primera es que el estado más profundo elige antes: al arrancar desde el atómico y ascender, la transición del hijo se encuentra antes que la del padre y corta la búsqueda. La segunda es que dentro de un mismo estado gana la primera declarada: si un estado tiene dos transiciones para el mismo evento con guardas distintas, el orden en que las escribes es el orden en que se prueban, y la primera que pase su guarda se lleva el paso. Por eso una transición sin guarda colocada antes que otra con guarda vuelve inalcanzable a la segunda.

Hay una tercera regla, menos conocida y responsable de bastante desconcierto: una guarda falsa no bloquea el ascenso. Si el hijo declara una transición para el evento pero su guarda no se cumple, el algoritmo no se detiene ahí ni concluye que el evento queda absorbido: sigue probando las demás transiciones del hijo y, si ninguna pasa, continúa subiendo hacia el padre, que puede quedarse con el evento. El comportamiento resultante es una cadena de responsabilidad de abajo arriba, y aprovecharla deliberadamente es idiomático: el hijo maneja el caso especial cuando se dan sus condiciones y deja que el padre aplique la política general en cualquier otro caso.

📝
Las guardas se evaluan durante la seleccion, no despues

Una consecuencia de que el filtrado ocurra en la fase de selección es que todas las guardas se evalúan sobre la misma configuración y el mismo context: el que había antes de que nada cambiara. Ninguna guarda ve el efecto de otra transición del mismo microstep, ni siquiera en regiones paralelas donde ambas van a ejecutarse. Por eso una guarda debe ser una función pura del estado previo y del evento, y por eso escribir guardas con efectos secundarios produce comportamientos que dependen de un orden de evaluación que la especificación nunca prometió mantener.

🪜

Prioridad vertical

Entre un hijo y un ancestro, gana el hijo. Es la regla de refinamiento: lo específico anula lo general, igual que una anulación de método anula la implementación heredada.

📄

Prioridad horizontal

Entre hermanas del mismo estado, gana la declarada antes. El orden documental deja de ser cosmético y pasa a ser semántico.

🚧

Guardas como filtro

Una transición cuya guarda no se cumple no es candidata y no bloquea la búsqueda: el algoritmo sigue probando las siguientes y luego sigue ascendiendo.

🧩

Una por región

El recorrido produce como máximo una candidata por estado atómico, de modo que en una máquina con regiones paralelas puede haber varias candidatas simultáneas y legítimas.

La preempción: el origen más descendiente desaloja

La selección ya sesga las cosas hacia lo profundo, pero no basta: en una máquina con regiones paralelas pueden llegar a la segunda fase varias candidatas cuyos conjuntos de salida sí se solapan. El procedimiento que limpia esa lista compara las candidatas por pares y aplica un criterio único: si sus conjuntos de salida se intersecan, sobrevive aquella cuyo estado de origen es descendiente del origen de la otra; y si ninguna es descendiente de la otra, sobrevive la que fue seleccionada antes, es decir, la de menor orden documental.

Situación Ganadora Regla aplicada
Hijo y ancestro, mismo evento La del hijo Origen más descendiente
Dos hermanas del mismo estado La declarada primero Orden documental
Dos regiones paralelas disjuntas Ambas No hay conflicto
Dos regiones paralelas que salen del parallel La de la región declarada primero Orden documental
Guarda falsa frente a guarda cierta La de guarda cierta Filtro previo a la selección

El resultado de aplicar selección y preempción se conoce como el conjunto óptimo de transiciones: el mayor conjunto de transiciones habilitadas y mutuamente compatibles que respeta las prioridades. Ese conjunto es el que se pasa al microstep de la lección anterior, y con él el algoritmo garantiza dos propiedades que dependen la una de la otra: determinismo, porque el conjunto es función únicamente de la configuración, el evento y el documento; y maximalidad, porque no descarta ninguna transición que pudiera haberse ejecutado sin interferir.

La palabra óptimo no es retórica: nombra un compromiso concreto entre dos exigencias que tiran en direcciones opuestas. La concurrencia pide ejecutar todo lo que pueda ejecutarse, porque si dos regiones ortogonales reaccionan al mismo evento y nada les impide hacerlo, suprimir una sería traicionar el significado de ortogonal. El determinismo pide suprimir todo lo ambiguo, porque dos transiciones que se disputan el mismo estado no pueden ganar las dos y elegir al azar destruiría la reproducibilidad. El conjunto óptimo satisface ambas exigencias con la única política que las hace compatibles: conservar el máximo posible sujeto a que no haya solapamiento, resolviendo cada solapamiento con un criterio total y sintáctico.

💡
Usa el orden como herramienta, no lo sufras

La forma idiomática de escribir transiciones condicionales aprovecha deliberadamente el orden documental: se declaran de la más específica a la más general y se deja la última sin guarda como caso por defecto. Es el mismo patrón que un switch con su rama final, y funciona por la misma razón. El error simétrico es igual de común: poner la transición sin guarda arriba convierte en código muerto todo lo que va debajo, y ninguna herramienta te avisará.

El punto de variación que casi nadie conoce

Que gane el hijo parece obvio hasta que descubres que no siempre fue así. La semántica original de STATEMATE, la herramienta con la que Harel y su equipo implementaron los statecharts en los años ochenta, da prioridad a la transición de origen más alto: cuando el padre y el hijo compiten, gana el padre. La justificación era razonable en su contexto de sistemas reactivos e ingeniería de control: una regla declarada arriba expresa una política global —una parada de emergencia, un modo degradado— que debe poder imponerse sobre cualquier refinamiento local.

UML eligió lo contrario, y SCXML siguió a UML. El argumento es el del refinamiento: un estado anidado es una especialización de su padre, y una especialización debe poder anular el comportamiento general del mismo modo que una subclase anula un método. Ninguna de las dos posturas es incorrecta; son elecciones distintas sobre qué significa la jerarquía, y son el ejemplo canónico de lo que la literatura llama un punto de variación semántica.

<!-- Bajo SCXML gana la transicion de Detalle. Bajo STATEMATE ganaria la de Panel. -->
<state id="Panel" initial="Lista">
  <transition event="ESC" target="Ajustes"/>
  <state id="Lista">
    <transition event="ABRIR" target="Detalle"/>
  </state>
  <state id="Detalle">
    <transition event="ESC" target="Lista"/>
  </state>
</state>

La lección práctica es que si necesitas el comportamiento de STATEMATE —una regla global que nadie pueda anular— no lo obtendrás peleándote con la prioridad, porque la prioridad no es configurable. Lo obtienes modelándolo: sacando esa transición a una región paralela que nadie más toca, de modo que deje de estar en conflicto con las locales y pueda dispararse junto a ellas en el mismo microstep.

Ese patrón merece un nombre porque aparece una y otra vez: la región supervisora. Consiste en envolver la máquina de trabajo en un parallel cuya segunda región contiene únicamente las transiciones que deben poder ocurrir siempre —la parada de emergencia, el cierre de sesión, la pérdida de conexión— y que al dispararse llevan a un estado desde el que se reconstruye todo. Como esa región es ortogonal a la de trabajo, ningún estado de trabajo puede interceptar sus eventos ni anular sus reglas, y el conflicto desaparece por construcción en lugar de resolverse por prioridad. Es la traducción idiomática de la intuición de STATEMATE a un sistema que eligió lo contrario.

⚠️
La preempcion no notifica

Cuando la transición del hijo gana, la del padre no se ejecuta y nadie te lo dice. No hay advertencia en consola, no hay error de tipos, no hay marca en el visualizador. Una regla declarada en la raíz que llevas meses creyendo activa puede estar siendo anulada sistemáticamente por un hijo añadido después, y el síntoma no será un fallo sino una ausencia: algo que debería pasar y no pasa. Es el argumento más fuerte para probar las transiciones globales desde configuraciones profundas y no solo desde la inicial.

El orden documental es la unica arbitrariedad honesta del sistema

Hay algo que incomoda profundamente en la regla de que gana la transición declarada primero. Todo lo demás en un statechart es estructura pura: la jerarquía significa refinamiento, la ortogonalidad significa independencia, las guardas significan condiciones del dominio. El orden documental no significa nada. Es la posición de un fragmento de texto en un archivo, un accidente de edición elevado a criterio de decisión, y es difícil no leerlo como una fealdad en un sistema por lo demás elegante. Pero la incomodidad se disuelve en cuanto uno se pregunta cuáles eran las alternativas, porque solo hay tres. Podía prohibirse la ambigüedad, exigiendo guardas mutuamente excluyentes y rechazando cualquier documento que no lo demostrara; eso requiere decidir la satisfacibilidad de predicados arbitrarios, que es indecidible en general, y habría convertido el estándar en algo que ninguna herramienta puede validar. Podía declararse la elección no determinista, dejando que cada intérprete escoja; eso destruye la portabilidad, que era la única razón de existir del estándar. O podía elegirse un criterio total, sintáctico y verificable por inspección, aunque no tuviera contenido semántico. El W3C eligió lo tercero, y al hacerlo aceptó un compromiso que merece admiración más que reproche: prefirió un desempate arbitrario pero visible a un desempate elegante pero incomputable, o a la ausencia de desempate disfrazada de libertad de implementación. Esa preferencia es una firma de madurez en el diseño de especificaciones. Un sistema formal no se juzga por no tener arbitrariedades —siempre las hay, porque la realidad no viene con desempates incorporados— sino por concentrarlas en el menor número posible de lugares, hacerlas explícitas y colocarlas donde el usuario pueda verlas y controlarlas. El orden en que escribes tus transiciones está a la vista, cabe en una línea de diferencia en un control de versiones y se comprueba leyendo. Es, probablemente, el mejor lugar del sistema entero donde esconder lo inevitable.

⚔️ Provocar y resolver colisiones
  1. Reproduce la máquina de Sesion, Panel y Detalle en XState v5 y comprueba empíricamente qué transición gana con ESC desde Detalle, desde Lista y desde fuera de Panel.
  2. Escribe un estado con tres transiciones para el mismo evento y guardas de especificidad decreciente. Después mueve la última al principio y explica qué transiciones acabas de volver inalcanzables.
  3. Modela una parada de emergencia que deba ganar siempre, primero como transición en la raíz —y observa cómo la anula cualquier hijo— y después como región paralela. Compara ambas soluciones.
  4. Construye dos regiones ortogonales que respondan al mismo evento sin salir de la región contraria. Verifica que ambas transiciones se ejecutan en el mismo microstep y determina en qué orden se aplican sus acciones.
  5. Documenta con un ejemplo propio un caso donde la semántica de STATEMATE habría dado un resultado distinto al de SCXML, y argumenta cuál de las dos habrías preferido para ese dominio concreto.