""" 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