207 lines
6.3 KiB
Python
207 lines
6.3 KiB
Python
"""
|
|
State Machine Model für den NSCT Research Orchestrator.
|
|
|
|
Definiert die Lebenszyklus-Zustände eines Research-Run und validiert
|
|
erlaubte Zustandübergänge entlang der Pipeline.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from enum import Enum
|
|
from typing import ClassVar, FrozenSet
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ResearchRunState(str, Enum):
|
|
"""Mögliche Zustände eines Research-Laufs entlang der Pipeline."""
|
|
|
|
# --- Initialisierung ---
|
|
CREATED = "created"
|
|
|
|
# --- Pipeline-Schritte ---
|
|
PLANNING = "planning"
|
|
SEARCHING = "searching"
|
|
FETCHING = "fetching"
|
|
EXTRACTING = "extracting"
|
|
ANALYZING = "analyzing"
|
|
EXPANDING = "expanding"
|
|
COMPARING = "comparing"
|
|
SYNTHESIZING = "synthesizing"
|
|
|
|
# --- Endzustände ---
|
|
COMPLETED = "completed"
|
|
FAILED = "failed"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
# Übergangsmatrix: Quelle -> zulässige Ziele (frozenset für Immutable)
|
|
_VALID_TRANSITIONS: dict[ResearchRunState, FrozenSet[ResearchRunState]] = {
|
|
ResearchRunState.CREATED: frozenset(
|
|
(ResearchRunState.PLANNING, ResearchRunState.FAILED)
|
|
),
|
|
ResearchRunState.PLANNING: frozenset(
|
|
(ResearchRunState.SEARCHING, ResearchRunState.FAILED)
|
|
),
|
|
ResearchRunState.SEARCHING: frozenset(
|
|
(ResearchRunState.FETCHING, ResearchRunState.FAILED)
|
|
),
|
|
ResearchRunState.FETCHING: frozenset(
|
|
(ResearchRunState.EXTRACTING, ResearchRunState.FAILED)
|
|
),
|
|
ResearchRunState.EXTRACTING: frozenset(
|
|
(ResearchRunState.ANALYZING, ResearchRunState.FAILED)
|
|
),
|
|
ResearchRunState.ANALYZING: frozenset(
|
|
(
|
|
ResearchRunState.EXPANDING,
|
|
ResearchRunState.COMPARING,
|
|
ResearchRunState.FAILED,
|
|
)
|
|
),
|
|
ResearchRunState.EXPANDING: frozenset(
|
|
(
|
|
ResearchRunState.ANALYZING, # zurück zur Analyse nach Expansion
|
|
ResearchRunState.COMPARING,
|
|
ResearchRunState.FAILED,
|
|
)
|
|
),
|
|
ResearchRunState.COMPARING: frozenset(
|
|
(ResearchRunState.SYNTHESIZING, ResearchRunState.FAILED)
|
|
),
|
|
ResearchRunState.SYNTHESIZING: frozenset(
|
|
(ResearchRunState.COMPLETED, ResearchRunState.FAILED)
|
|
),
|
|
# Terminal-States: keine ausgehenden Übergänge.
|
|
ResearchRunState.COMPLETED: frozenset(),
|
|
ResearchRunState.FAILED: frozenset(),
|
|
ResearchRunState.CANCELLED: frozenset(),
|
|
}
|
|
|
|
|
|
class StateMachine:
|
|
"""Endlicher Automat für den Lebenszyklus eines Research-Runs.
|
|
|
|
Parameters
|
|
----------
|
|
initial_state : ResearchRunState
|
|
Der Startzustand der Maschine (default: CREATED).
|
|
|
|
Attributes
|
|
----------
|
|
current_state : ResearchRunState
|
|
Der aktuell eingestellte Zustand.
|
|
"""
|
|
|
|
# Die erlaubten Übergänge sind klassengebunden und typstarr.
|
|
_TRANSITIONS: ClassVar[dict[ResearchRunState, FrozenSet[ResearchRunState]]] = _VALID_TRANSITIONS
|
|
|
|
def __init__(self, initial_state: ResearchRunState = ResearchRunState.CREATED) -> None:
|
|
self.current_state: ResearchRunState = initial_state
|
|
|
|
def transition(self, new_state: str) -> bool:
|
|
"""Führt einen Zustandübergang aus, wenn er erlaubt ist.
|
|
|
|
Parameters
|
|
----------
|
|
new_state : str
|
|
Der gewünschte Zielzustand als String (z. B. ``"planning"``).
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
``True`` wenn der Übergang erfolgreich war, ``False`` sonst
|
|
(z. B. bei einem unerlaubten Übergang oder Terminal-State).
|
|
"""
|
|
target: ResearchRunState | None = None
|
|
try:
|
|
target = ResearchRunState(new_state)
|
|
except ValueError:
|
|
logger.info(
|
|
"Unknown state '%s' — transition %s -> %s rejected",
|
|
self.current_state.value,
|
|
new_state,
|
|
)
|
|
return False
|
|
|
|
return self._apply(self.current_state, target)
|
|
|
|
def is_terminal(self) -> bool:
|
|
"""Prüft, ob die Maschine sich in einem Terminal-State befindet."""
|
|
return self.current_state in (
|
|
ResearchRunState.COMPLETED,
|
|
ResearchRunState.FAILED,
|
|
ResearchRunState.CANCELLED,
|
|
)
|
|
|
|
def reset(self, state: ResearchRunState | None = None) -> None:
|
|
"""Setzt die Maschine zurück (z. B. vor einem neuen Run).
|
|
|
|
Parameters
|
|
----------
|
|
state : ResearchRunState | None
|
|
Zielzustand; default: CREATED.
|
|
"""
|
|
self.current_state = state or ResearchRunState.CREATED
|
|
|
|
def _apply(self, from_state: ResearchRunState, to_state: ResearchRunState) -> bool:
|
|
"""Interne Übergangslogik mit Logging.
|
|
|
|
Parameters
|
|
----------
|
|
from_state : ResearchRunState
|
|
Der aktuelle Zustand (zur Validierung).
|
|
to_state : ResearchRunState
|
|
Der gewünschte neue Zustand.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
``True`` wenn der Übergang ausgeführt wurde, ``False`` sonst.
|
|
"""
|
|
allowed: FrozenSet[ResearchRunState] = _VALID_TRANSITIONS.get(from_state)
|
|
if allowed is None:
|
|
raise ValueError(
|
|
f"No outgoing transitions defined for state {from_state.value!r}"
|
|
)
|
|
|
|
# Terminal-States blockieren jeglichen Ausgang
|
|
if from_state in (
|
|
ResearchRunState.COMPLETED,
|
|
ResearchRunState.FAILED,
|
|
ResearchRunState.CANCELLED,
|
|
):
|
|
logger.info(
|
|
"Terminal state %s reached — transition %s -> %s rejected",
|
|
from_state.value,
|
|
from_state.value,
|
|
to_state.value,
|
|
)
|
|
return False
|
|
|
|
# Selber-Stehen ist kein Fehler
|
|
if from_state == to_state:
|
|
logger.info(
|
|
"No-op: state already %s — skipping transition",
|
|
from_state.value,
|
|
)
|
|
return False
|
|
|
|
if to_state not in allowed:
|
|
logger.info(
|
|
"Transition %s -> %s rejected — not allowed",
|
|
from_state.value,
|
|
to_state.value,
|
|
)
|
|
return False
|
|
|
|
# Erlaubt: tatsächlich durchführen
|
|
self.current_state = to_state # type: ignore[assignment] # instance attr
|
|
logger.info(
|
|
"Transition %s -> %s accepted",
|
|
from_state.value,
|
|
to_state.value,
|
|
)
|
|
return True
|