From 0728c21520535d132447aab3c392d860b9ccf7e6 Mon Sep 17 00:00:00 2001 From: Peter Bednarik Date: Mon, 23 Feb 2026 15:40:39 +0200 Subject: [PATCH 01/13] Nautilus - models and schemas for /navigate and /initialize. --- desdeo/api/models/nautilus.py | 44 ++++++++++++++++++++++++ desdeo/api/schemas/__init__.py | 6 ++++ desdeo/api/schemas/nautilus.py | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 desdeo/api/models/nautilus.py create mode 100644 desdeo/api/schemas/__init__.py create mode 100644 desdeo/api/schemas/nautilus.py diff --git a/desdeo/api/models/nautilus.py b/desdeo/api/models/nautilus.py new file mode 100644 index 000000000..027697050 --- /dev/null +++ b/desdeo/api/models/nautilus.py @@ -0,0 +1,44 @@ +import datetime + +from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String + +from desdeo.api.db import Base + + +class NautilusStateDB(Base): + """Database model storing the state of a NAUTILUS interactive navigation session. + + Each record represents a single interaction step within a NAUTILUS session, + such as initialization, navigation, or finalization. The table supports + branching session trees by maintaining parent-child relationships between states. + + Attributes: + id (int): Primary key identifying this state entry. + session_id (int): Identifier of the interactive NAUTILUS session. + parent_state_id (int | None): Foreign key referencing the parent state. + Null for root (initialization) nodes. + request (JSON): Serialized Nautilus request model used to generate this state. + response (JSON): Serialized Nautilus response model produced by the algorithm. + node_type (str): Type of interaction that generated the state. + Expected values include: + - "initialize" + - "navigate" + - "final" + depth (int): Depth of this node within the session tree (root = 0). + created_at (datetime): Timestamp when this state was created. + """ + + __tablename__ = "nautilus_states" + + id = Column(Integer, primary_key=True) + session_id = Column(Integer, nullable=False) + parent_state_id = Column(Integer, ForeignKey("nautilus_states.id"), nullable=True) + + request = Column(JSON, nullable=False) + response = Column(JSON, nullable=False) + + node_type = Column(String, nullable=False) # "initialize", "navigate", "final" + + depth = Column(Integer, nullable=False) + + created_at = Column(DateTime, default=datetime.utcnow) diff --git a/desdeo/api/schemas/__init__.py b/desdeo/api/schemas/__init__.py new file mode 100644 index 000000000..36827e27c --- /dev/null +++ b/desdeo/api/schemas/__init__.py @@ -0,0 +1,6 @@ +from .nautilus import ( + NautilusInitialResponse, + NautilusInitRequest, + NautilusNavigateRequest, + NautilusResponse, +) diff --git a/desdeo/api/schemas/nautilus.py b/desdeo/api/schemas/nautilus.py new file mode 100644 index 000000000..90299857d --- /dev/null +++ b/desdeo/api/schemas/nautilus.py @@ -0,0 +1,61 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +# Requests +class NautilusInitRequest(BaseModel): + """Request to initialize a NAUTILUS Navigator session for a specific problem.""" + + problem_id: int = Field(..., description="The ID of the problem to navigate.") + total_steps: int = Field(100, description="The total number of steps in the NAUTILUS Navigator.") + +class NautilusNavigateRequest(BaseModel): + """Request to navigate a NAUTILUS Navigator session.""" + + problem_id: int = Field(..., description="The ID of the problem to navigate.") + preference: dict[str, float] = Field(..., description="The preference of the Decision Maker (DM) for each objective.") + bounds: dict[str, float] = Field(..., description="The bounds preference of the DM for each objective.") + go_back_step: int = Field(..., description="The step index to go back in the navigation history.") + steps_remaining: int = Field(..., description="The number of steps remaining in the navigation process.") + +# Responses +class NautilusInitialResponse(BaseModel): + """Response returned by the NAUTILUS Navigator when initialized.""" + + state_id: int = Field(..., description="The ID of this navigation state.") + response_type: Literal["nautilus.initialize"] = "nautilus.initialize" + node_type: Literal["initialize"] = "initialize" + parent_state_id: int | None = Field(None, description="Parent state ID, if this is a child step.") + + objective_symbols: list[str] = Field(..., description="The symbols of the objectives.") + objective_long_names: list[str] = Field(..., description="Long/descriptive names of the objectives.") + units: list[str] | None = Field(None, description="The units of the objectives, empty if unitless.") + is_maximized: list[bool] = Field(..., description="Whether each objective is to be maximized.") + ideal: list[float] = Field(..., description="The ideal values of the objectives.") + nadir: list[float] = Field(..., description="The nadir values of the objectives.") + total_steps: int = Field(..., description="The total number of steps in this NAUTILUS session.") + + +class NautilusNavigateResponse(BaseModel): + """Response returned by the NAUTILUS Navigator during navigation (modern ENautilus style).""" + + state_id: int = Field(..., description="The ID of this navigation state.") + response_type: Literal["nautilus.navigate"] = "nautilus.navigate" + node_type: Literal["navigate", "final"] = "navigate" + parent_state_id: int | None = Field(None, description="Parent state ID, if this is a child step.") + + objective_symbols: list[str] = Field(..., description="The symbols of the objectives.") + objective_long_names: list[str] = Field(..., description="Long/descriptive names of the objectives.") + units: list[str] | None = Field(None, description="The units of the objectives, empty if unitless.") + is_maximized: list[bool] = Field(..., description="Whether each objective is to be maximized.") + ideal: list[float] = Field(..., description="The ideal values of the objectives.") + nadir: list[float] = Field(..., description="The nadir values of the objectives.") + + lower_bounds: dict[str, list[float]] = Field(..., description="Lower bounds of the reachable region per objective.") + upper_bounds: dict[str, list[float]] = Field(..., description="Upper bounds of the reachable region per objective.") + preferences: dict[str, list[float]] = Field(..., description="Preferences used in each step per objective.") + bounds: dict[str, list[float]] = Field(..., description="Bounds used in each step per objective.") + + total_steps: int = Field(..., description="The total number of steps in the current navigation path.") + reachable_solution: dict = Field(..., description="The solution reached at the end of navigation.") \ No newline at end of file From d2a96af2332cd68d556ccbab227baa4d11025a2a Mon Sep 17 00:00:00 2001 From: Peter Bednarik Date: Wed, 4 Mar 2026 10:33:18 +0200 Subject: [PATCH 02/13] StateDB used, node_type in schemas removed, generic_states additions but question as well. --- desdeo/api/models/generic_states.py | 144 ++++++++++++++++++++-------- desdeo/api/models/nautilus.py | 79 +++++++++------ desdeo/api/schemas/nautilus.py | 4 +- 3 files changed, 154 insertions(+), 73 deletions(-) diff --git a/desdeo/api/models/generic_states.py b/desdeo/api/models/generic_states.py index 7baeb2a81..63df735ff 100644 --- a/desdeo/api/models/generic_states.py +++ b/desdeo/api/models/generic_states.py @@ -28,6 +28,7 @@ GNIMBUSOptimizationState, GNIMBUSVotingState, IntermediateSolutionState, + NautilusState, NIMBUSClassificationState, NIMBUSFinalState, NIMBUSInitializationState, @@ -64,6 +65,8 @@ class StateKind(str, Enum): GENERIC_INTERMEDIATE = "generic.solve_intermediate" ENAUTILUS_STEP = "e-nautilus.stepping" ENAUTILUS_FINAL = "e-nautilus.final" + NAUTILUS_NAVIGATE = "nautilus.navigate" + NAUTILUS_INITIALIZE = "nautilus.initialize" class State(SQLModel, table=True): @@ -117,43 +120,100 @@ class StateDB(SQLModel, table=True): session: "InteractiveSessionDB" = Relationship(back_populates="states") problem: "ProblemDB" = Relationship(back_populates="states") + # @classmethod + # def create( + # cls, + # database_session: Session, + # *, + # problem_id: int | None = None, + # session_id: int | None = None, + # parent_id: int | None = None, + # state: SQLModel | None = None, + # ) -> "StateDB": + # """Build a StateDB + base State with a concrete substate.""" + # sub_cls = type(state) + # kind: StateKind | None = None + + # for cls_in_mro in sub_cls.mro(): + # if cls_in_mro in SUBSTATE_TO_KIND: + # kind = SUBSTATE_TO_KIND[cls_in_mro] + # break + + # if kind is None: + # raise ValueError(f"No StateKind mapping for substate type {sub_cls!r}") + + # method, phase = _method_phase_from_kind(kind) + # base = State(method=method, phase=phase, kind=kind) + + # row = cls( + # problem_id=problem_id, + # session_id=session_id, + # parent_id=parent_id, + # base_state=base, + # ) + # database_session.add(row) + + # # Persist base and link substate PK=FK + # _attach_substate(database_session, base, state) + + # return row + @classmethod def create( cls, database_session: Session, *, + kind: StateKind, problem_id: int | None = None, session_id: int | None = None, parent_id: int | None = None, state: SQLModel | None = None, ) -> "StateDB": - """Build a StateDB + base State with a concrete substate.""" - sub_cls = type(state) - kind: StateKind | None = None - - for cls_in_mro in sub_cls.mro(): - if cls_in_mro in SUBSTATE_TO_KIND: - kind = SUBSTATE_TO_KIND[cls_in_mro] - break - - if kind is None: - raise ValueError(f"No StateKind mapping for substate type {sub_cls!r}") + """Create a new StateDB entry and corresponding substate entry. - method, phase = _method_phase_from_kind(kind) - base = State(method=method, phase=phase, kind=kind) + Args: + database_session: Database session + kind: Explicit StateKind of this state. + problem_id: Required for root states. + session_id: Required for child states. + parent_id: Parent state ID if this is a child. + state: The substate SQLModel instance. + """ + if state is None: + raise ValueError("State (substate) must be provided.") + + if parent_id is None: + # Root state + if problem_id is None: + raise ValueError("Root state requires problem_id.") + state_db = cls( + problem_id=problem_id, + session_id=None, + parent_id=None, + kind=kind, + ) + else: + # Child state + if session_id is None: + raise ValueError("Child state requires session_id.") + state_db = cls( + problem_id=None, + session_id=session_id, + parent_id=parent_id, + kind=kind, + ) - row = cls( - problem_id=problem_id, - session_id=session_id, - parent_id=parent_id, - base_state=base, - ) - database_session.add(row) + database_session.add(state_db) + database_session.commit() + database_session.refresh(state_db) - # Persist base and link substate PK=FK - _attach_substate(database_session, base, state) + # Link substate + state.state_id = state_db.id + database_session.add(state) + database_session.commit() + database_session.refresh(state_db) - return row + return state_db @property def state(self) -> SQLModel | None: @@ -194,25 +254,29 @@ def state(self) -> SQLModel | None: StateKind.GENERIC_INTERMEDIATE: IntermediateSolutionState, StateKind.ENAUTILUS_STEP: ENautilusState, StateKind.ENAUTILUS_FINAL: ENautilusFinalState, + StateKind.NAUTILUS_NAVIGATE: NautilusState, + StateKind.NAUTILUS_INITIALIZE: NautilusState } -SUBSTATE_TO_KIND: dict[SQLModel, StateKind] = { - RPMState: StateKind.RPM_SOLVE, - NIMBUSClassificationState: StateKind.NIMBUS_SOLVE, - NIMBUSSaveState: StateKind.NIMBUS_SAVE, - NIMBUSInitializationState: StateKind.NIMBUS_INIT, - NIMBUSFinalState: StateKind.NIMBUS_FINAL, - EMOIterateState: StateKind.EMO_RUN, - GNIMBUSOptimizationState: StateKind.GNIMBUS_OPTIMIZE, - GNIMBUSVotingState: StateKind.GNIMBUS_VOTE, - GNIMBUSEndState: StateKind.GNIMBUS_END, - EMOSaveState: StateKind.EMO_SAVE, - EMOFetchState: StateKind.EMO_FETCH, - EMOSCOREState: StateKind.EMO_SCORE, - IntermediateSolutionState: StateKind.GENERIC_INTERMEDIATE, - ENautilusState: StateKind.ENAUTILUS_STEP, - ENautilusFinalState: StateKind.ENAUTILUS_FINAL, -} +# SUBSTATE_TO_KIND: dict[SQLModel, StateKind] = { +# RPMState: StateKind.RPM_SOLVE, +# NIMBUSClassificationState: StateKind.NIMBUS_SOLVE, +# NIMBUSSaveState: StateKind.NIMBUS_SAVE, +# NIMBUSInitializationState: StateKind.NIMBUS_INIT, +# NIMBUSFinalState: StateKind.NIMBUS_FINAL, +# EMOIterateState: StateKind.EMO_RUN, +# GNIMBUSOptimizationState: StateKind.GNIMBUS_OPTIMIZE, +# GNIMBUSVotingState: StateKind.GNIMBUS_VOTE, +# GNIMBUSEndState: StateKind.GNIMBUS_END, +# EMOSaveState: StateKind.EMO_SAVE, +# EMOFetchState: StateKind.EMO_FETCH, +# EMOSCOREState: StateKind.EMO_SCORE, +# IntermediateSolutionState: StateKind.GENERIC_INTERMEDIATE, +# ENautilusState: StateKind.ENAUTILUS_STEP, +# ENautilusFinalState: StateKind.ENAUTILUS_FINAL, +# # NautilusState: StateKind.NAUTILUS_NAVIGATE, +# # NautilusState: StateKind.NAUTILUS_INITIALIZE +# } def _method_phase_from_kind(kind: StateKind) -> tuple[str, str]: diff --git a/desdeo/api/models/nautilus.py b/desdeo/api/models/nautilus.py index 027697050..4db5e6281 100644 --- a/desdeo/api/models/nautilus.py +++ b/desdeo/api/models/nautilus.py @@ -1,44 +1,63 @@ -import datetime +from sqlmodel import JSON, Column, Field, SQLModel -from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String -from desdeo.api.db import Base +class NautilusState(SQLModel, table=True): + """Concrete NAUTILUS Navigator state stored for a single interaction step. + This model stores the full algorithmic state returned by the + NAUTILUS Navigator after either initialization or a navigation step. -class NautilusStateDB(Base): - """Database model storing the state of a NAUTILUS interactive navigation session. - - Each record represents a single interaction step within a NAUTILUS session, - such as initialization, navigation, or finalization. The table supports - branching session trees by maintaining parent-child relationships between states. + The instance is linked to a base `StateDB` entry, which defines the + problem context and state type (e.g. "nautilus.initialize" or + "nautilus.navigate"). This table contains only the algorithm-specific + data required to reconstruct the navigation process at that step. Attributes: - id (int): Primary key identifying this state entry. - session_id (int): Identifier of the interactive NAUTILUS session. - parent_state_id (int | None): Foreign key referencing the parent state. - Null for root (initialization) nodes. - request (JSON): Serialized Nautilus request model used to generate this state. - response (JSON): Serialized Nautilus response model produced by the algorithm. - node_type (str): Type of interaction that generated the state. - Expected values include: - - "initialize" - - "navigate" - - "final" - depth (int): Depth of this node within the session tree (root = 0). - created_at (datetime): Timestamp when this state was created. + id (int | None): Primary key of this NAUTILUS state entry. + objective_symbols (list[str]): Short symbolic names of the objectives. + objective_long_names (list[str]): Descriptive names of the objectives. + units (list[str] | None): Units of the objectives, if defined. None if unitless. + is_maximized (list[bool]): Boolean flags indicating whether each objective + is to be maximized (True) or minimized (False). + ideal (list[float]): Ideal objective values of the problem. + nadir (list[float]): Nadir objective values of the problem. + lower_bounds (dict[str, list[float]]): Lower bounds of the reachable region per objective + across navigation steps. + upper_bounds (dict[str, list[float]]): Upper bounds of the reachable region per objective + across navigation steps. + preferences (dict[str, list[float]]): Preference values provided by the decision maker + for each navigation step. + bounds (dict[str, list[float]]): Bound preferences provided by the decision maker + for each navigation step. + total_steps (int): Total number of steps allowed in this NAUTILUS session. + current_step (int): Current navigation step index. + remaining_steps (int): Number of steps remaining in the navigation process. + reachable_solution (dict[str, float]): The objective values of the currently reachable solution. """ __tablename__ = "nautilus_states" - id = Column(Integer, primary_key=True) - session_id = Column(Integer, nullable=False) - parent_state_id = Column(Integer, ForeignKey("nautilus_states.id"), nullable=True) + id: int | None = Field(default=None, primary_key=True) + + # Problem meta + objective_symbols: list[str] = Field(sa_column=Column(JSON)) + objective_long_names: list[str] = Field(sa_column=Column(JSON)) + units: list[str] | None = Field(default=None, sa_column=Column(JSON)) + is_maximized: list[bool] = Field(sa_column=Column(JSON)) - request = Column(JSON, nullable=False) - response = Column(JSON, nullable=False) + # Problem bounds + ideal: list[float] = Field(sa_column=Column(JSON)) + nadir: list[float] = Field(sa_column=Column(JSON)) - node_type = Column(String, nullable=False) # "initialize", "navigate", "final" + # Navigation data + lower_bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) + upper_bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) + preferences: dict[str, list[float]] = Field(sa_column=Column(JSON)) + bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) - depth = Column(Integer, nullable=False) + total_steps: int + current_step: int + remaining_steps: int - created_at = Column(DateTime, default=datetime.utcnow) + # Final reachable solution + reachable_solution: dict[str, float] = Field(sa_column=Column(JSON)) diff --git a/desdeo/api/schemas/nautilus.py b/desdeo/api/schemas/nautilus.py index 90299857d..49543fa7d 100644 --- a/desdeo/api/schemas/nautilus.py +++ b/desdeo/api/schemas/nautilus.py @@ -25,7 +25,6 @@ class NautilusInitialResponse(BaseModel): state_id: int = Field(..., description="The ID of this navigation state.") response_type: Literal["nautilus.initialize"] = "nautilus.initialize" - node_type: Literal["initialize"] = "initialize" parent_state_id: int | None = Field(None, description="Parent state ID, if this is a child step.") objective_symbols: list[str] = Field(..., description="The symbols of the objectives.") @@ -42,7 +41,6 @@ class NautilusNavigateResponse(BaseModel): state_id: int = Field(..., description="The ID of this navigation state.") response_type: Literal["nautilus.navigate"] = "nautilus.navigate" - node_type: Literal["navigate", "final"] = "navigate" parent_state_id: int | None = Field(None, description="Parent state ID, if this is a child step.") objective_symbols: list[str] = Field(..., description="The symbols of the objectives.") @@ -58,4 +56,4 @@ class NautilusNavigateResponse(BaseModel): bounds: dict[str, list[float]] = Field(..., description="Bounds used in each step per objective.") total_steps: int = Field(..., description="The total number of steps in the current navigation path.") - reachable_solution: dict = Field(..., description="The solution reached at the end of navigation.") \ No newline at end of file + reachable_solution: dict[str, float] = Field(..., description="The solution reached at the end of navigation.") From 654f27d171ee6dae475822c5add2127e45774023 Mon Sep 17 00:00:00 2001 From: Peter Bednarik Date: Wed, 4 Mar 2026 10:43:36 +0200 Subject: [PATCH 03/13] Repair, new version disabled. --- desdeo/api/models/generic_states.py | 202 ++++++++++++++-------------- 1 file changed, 102 insertions(+), 100 deletions(-) diff --git a/desdeo/api/models/generic_states.py b/desdeo/api/models/generic_states.py index 63df735ff..26054a1ce 100644 --- a/desdeo/api/models/generic_states.py +++ b/desdeo/api/models/generic_states.py @@ -17,6 +17,9 @@ from desdeo.problem import Tensor, VariableType +from .nautilus import ( + NautilusState, +) from .state import ( EMOFetchState, EMOIterateState, @@ -28,7 +31,6 @@ GNIMBUSOptimizationState, GNIMBUSVotingState, IntermediateSolutionState, - NautilusState, NIMBUSClassificationState, NIMBUSFinalState, NIMBUSInitializationState, @@ -120,100 +122,100 @@ class StateDB(SQLModel, table=True): session: "InteractiveSessionDB" = Relationship(back_populates="states") problem: "ProblemDB" = Relationship(back_populates="states") - # @classmethod - # def create( - # cls, - # database_session: Session, - # *, - # problem_id: int | None = None, - # session_id: int | None = None, - # parent_id: int | None = None, - # state: SQLModel | None = None, - # ) -> "StateDB": - # """Build a StateDB + base State with a concrete substate.""" - # sub_cls = type(state) - # kind: StateKind | None = None - - # for cls_in_mro in sub_cls.mro(): - # if cls_in_mro in SUBSTATE_TO_KIND: - # kind = SUBSTATE_TO_KIND[cls_in_mro] - # break - - # if kind is None: - # raise ValueError(f"No StateKind mapping for substate type {sub_cls!r}") - - # method, phase = _method_phase_from_kind(kind) - # base = State(method=method, phase=phase, kind=kind) - - # row = cls( - # problem_id=problem_id, - # session_id=session_id, - # parent_id=parent_id, - # base_state=base, - # ) - # database_session.add(row) - - # # Persist base and link substate PK=FK - # _attach_substate(database_session, base, state) - - # return row - @classmethod def create( cls, database_session: Session, *, - kind: StateKind, problem_id: int | None = None, session_id: int | None = None, parent_id: int | None = None, state: SQLModel | None = None, ) -> "StateDB": - """Create a new StateDB entry and corresponding substate entry. + """Build a StateDB + base State with a concrete substate.""" + sub_cls = type(state) + kind: StateKind | None = None - Args: - database_session: Database session - kind: Explicit StateKind of this state. - problem_id: Required for root states. - session_id: Required for child states. - parent_id: Parent state ID if this is a child. - state: The substate SQLModel instance. - """ - if state is None: - raise ValueError("State (substate) must be provided.") - - if parent_id is None: - # Root state - if problem_id is None: - raise ValueError("Root state requires problem_id.") - state_db = cls( - problem_id=problem_id, - session_id=None, - parent_id=None, - kind=kind, - ) - else: - # Child state - if session_id is None: - raise ValueError("Child state requires session_id.") - state_db = cls( - problem_id=None, - session_id=session_id, - parent_id=parent_id, - kind=kind, - ) + for cls_in_mro in sub_cls.mro(): + if cls_in_mro in SUBSTATE_TO_KIND: + kind = SUBSTATE_TO_KIND[cls_in_mro] + break + + if kind is None: + raise ValueError(f"No StateKind mapping for substate type {sub_cls!r}") - database_session.add(state_db) - database_session.commit() - database_session.refresh(state_db) + method, phase = _method_phase_from_kind(kind) + base = State(method=method, phase=phase, kind=kind) - # Link substate - state.state_id = state_db.id - database_session.add(state) - database_session.commit() - database_session.refresh(state_db) + row = cls( + problem_id=problem_id, + session_id=session_id, + parent_id=parent_id, + base_state=base, + ) + database_session.add(row) + + # Persist base and link substate PK=FK + _attach_substate(database_session, base, state) - return state_db + return row + + # @classmethod + # def create( + # cls, + # database_session: Session, + # *, + # kind: StateKind, + # problem_id: int | None = None, + # session_id: int | None = None, + # parent_id: int | None = None, + # state: SQLModel | None = None, + # ) -> "StateDB": + # """Create a new StateDB entry and corresponding substate entry. + + # Args: + # database_session: Database session + # kind: Explicit StateKind of this state. + # problem_id: Required for root states. + # session_id: Required for child states. + # parent_id: Parent state ID if this is a child. + # state: The substate SQLModel instance. + # """ + # if state is None: + # raise ValueError("State (substate) must be provided.") + + # if parent_id is None: + # # Root state + # if problem_id is None: + # raise ValueError("Root state requires problem_id.") + # state_db = cls( + # problem_id=problem_id, + # session_id=None, + # parent_id=None, + # kind=kind, + # ) + # else: + # # Child state + # if session_id is None: + # raise ValueError("Child state requires session_id.") + # state_db = cls( + # problem_id=None, + # session_id=session_id, + # parent_id=parent_id, + # kind=kind, + # ) + + # database_session.add(state_db) + # database_session.commit() + # database_session.refresh(state_db) + + # # Link substate + # state.state_id = state_db.id + # database_session.add(state) + # database_session.commit() + # database_session.refresh(state_db) + + # return state_db @property def state(self) -> SQLModel | None: @@ -258,25 +260,25 @@ def state(self) -> SQLModel | None: StateKind.NAUTILUS_INITIALIZE: NautilusState } -# SUBSTATE_TO_KIND: dict[SQLModel, StateKind] = { -# RPMState: StateKind.RPM_SOLVE, -# NIMBUSClassificationState: StateKind.NIMBUS_SOLVE, -# NIMBUSSaveState: StateKind.NIMBUS_SAVE, -# NIMBUSInitializationState: StateKind.NIMBUS_INIT, -# NIMBUSFinalState: StateKind.NIMBUS_FINAL, -# EMOIterateState: StateKind.EMO_RUN, -# GNIMBUSOptimizationState: StateKind.GNIMBUS_OPTIMIZE, -# GNIMBUSVotingState: StateKind.GNIMBUS_VOTE, -# GNIMBUSEndState: StateKind.GNIMBUS_END, -# EMOSaveState: StateKind.EMO_SAVE, -# EMOFetchState: StateKind.EMO_FETCH, -# EMOSCOREState: StateKind.EMO_SCORE, -# IntermediateSolutionState: StateKind.GENERIC_INTERMEDIATE, -# ENautilusState: StateKind.ENAUTILUS_STEP, -# ENautilusFinalState: StateKind.ENAUTILUS_FINAL, -# # NautilusState: StateKind.NAUTILUS_NAVIGATE, -# # NautilusState: StateKind.NAUTILUS_INITIALIZE -# } +SUBSTATE_TO_KIND: dict[SQLModel, StateKind] = { + RPMState: StateKind.RPM_SOLVE, + NIMBUSClassificationState: StateKind.NIMBUS_SOLVE, + NIMBUSSaveState: StateKind.NIMBUS_SAVE, + NIMBUSInitializationState: StateKind.NIMBUS_INIT, + NIMBUSFinalState: StateKind.NIMBUS_FINAL, + EMOIterateState: StateKind.EMO_RUN, + GNIMBUSOptimizationState: StateKind.GNIMBUS_OPTIMIZE, + GNIMBUSVotingState: StateKind.GNIMBUS_VOTE, + GNIMBUSEndState: StateKind.GNIMBUS_END, + EMOSaveState: StateKind.EMO_SAVE, + EMOFetchState: StateKind.EMO_FETCH, + EMOSCOREState: StateKind.EMO_SCORE, + IntermediateSolutionState: StateKind.GENERIC_INTERMEDIATE, + ENautilusState: StateKind.ENAUTILUS_STEP, + ENautilusFinalState: StateKind.ENAUTILUS_FINAL, + # NautilusState: StateKind.NAUTILUS_NAVIGATE, + # NautilusState: StateKind.NAUTILUS_INITIALIZE +} def _method_phase_from_kind(kind: StateKind) -> tuple[str, str]: From 1085ee0ea9e9c54269b93287bac03f0c837fdc51 Mon Sep 17 00:00:00 2001 From: Peter Bednarik Date: Mon, 9 Mar 2026 10:30:24 +0200 Subject: [PATCH 04/13] Two states - NautilusNavigatorInitializationState and NautilusNavigatorNavigationState --- desdeo/api/models/generic_states.py | 69 ++-------------- desdeo/api/models/nautilus.py | 119 ++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 95 deletions(-) diff --git a/desdeo/api/models/generic_states.py b/desdeo/api/models/generic_states.py index 26054a1ce..45b38bb34 100644 --- a/desdeo/api/models/generic_states.py +++ b/desdeo/api/models/generic_states.py @@ -17,9 +17,7 @@ from desdeo.problem import Tensor, VariableType -from .nautilus import ( - NautilusState, -) +from .nautilus import NautilusNavigatorInitializationState, NautilusNavigatorNavigationState from .state import ( EMOFetchState, EMOIterateState, @@ -160,63 +158,6 @@ def create( return row - # @classmethod - # def create( - # cls, - # database_session: Session, - # *, - # kind: StateKind, - # problem_id: int | None = None, - # session_id: int | None = None, - # parent_id: int | None = None, - # state: SQLModel | None = None, - # ) -> "StateDB": - # """Create a new StateDB entry and corresponding substate entry. - - # Args: - # database_session: Database session - # kind: Explicit StateKind of this state. - # problem_id: Required for root states. - # session_id: Required for child states. - # parent_id: Parent state ID if this is a child. - # state: The substate SQLModel instance. - # """ - # if state is None: - # raise ValueError("State (substate) must be provided.") - - # if parent_id is None: - # # Root state - # if problem_id is None: - # raise ValueError("Root state requires problem_id.") - # state_db = cls( - # problem_id=problem_id, - # session_id=None, - # parent_id=None, - # kind=kind, - # ) - # else: - # # Child state - # if session_id is None: - # raise ValueError("Child state requires session_id.") - # state_db = cls( - # problem_id=None, - # session_id=session_id, - # parent_id=parent_id, - # kind=kind, - # ) - - # database_session.add(state_db) - # database_session.commit() - # database_session.refresh(state_db) - - # # Link substate - # state.state_id = state_db.id - # database_session.add(state) - # database_session.commit() - # database_session.refresh(state_db) - - # return state_db - @property def state(self) -> SQLModel | None: """Return the concrete substate instance (e.g., NIMBUSSaveState)... @@ -256,8 +197,8 @@ def state(self) -> SQLModel | None: StateKind.GENERIC_INTERMEDIATE: IntermediateSolutionState, StateKind.ENAUTILUS_STEP: ENautilusState, StateKind.ENAUTILUS_FINAL: ENautilusFinalState, - StateKind.NAUTILUS_NAVIGATE: NautilusState, - StateKind.NAUTILUS_INITIALIZE: NautilusState + StateKind.NAUTILUS_NAVIGATE: NautilusNavigatorNavigationState, + StateKind.NAUTILUS_INITIALIZE: NautilusNavigatorInitializationState } SUBSTATE_TO_KIND: dict[SQLModel, StateKind] = { @@ -276,8 +217,8 @@ def state(self) -> SQLModel | None: IntermediateSolutionState: StateKind.GENERIC_INTERMEDIATE, ENautilusState: StateKind.ENAUTILUS_STEP, ENautilusFinalState: StateKind.ENAUTILUS_FINAL, - # NautilusState: StateKind.NAUTILUS_NAVIGATE, - # NautilusState: StateKind.NAUTILUS_INITIALIZE + NautilusNavigatorNavigationState: StateKind.NAUTILUS_NAVIGATE, + NautilusNavigatorInitializationState: StateKind.NAUTILUS_INITIALIZE } diff --git a/desdeo/api/models/nautilus.py b/desdeo/api/models/nautilus.py index 4db5e6281..80ec8abf4 100644 --- a/desdeo/api/models/nautilus.py +++ b/desdeo/api/models/nautilus.py @@ -1,43 +1,46 @@ from sqlmodel import JSON, Column, Field, SQLModel -class NautilusState(SQLModel, table=True): - """Concrete NAUTILUS Navigator state stored for a single interaction step. +class NautilusNavigatorInitializationState(SQLModel, table=True): + """State storing the inputs and outputs of the NAUTILUS Navigator initialization. - This model stores the full algorithmic state returned by the - NAUTILUS Navigator after either initialization or a navigation step. + This state corresponds to the execution of the `navigator_init` function in the + NAUTILUS Navigator core algorithm. It stores both the request provided by the + user and the resulting initialization information returned by the algorithm. - The instance is linked to a base `StateDB` entry, which defines the - problem context and state type (e.g. "nautilus.initialize" or - "nautilus.navigate"). This table contains only the algorithm-specific - data required to reconstruct the navigation process at that step. + The state is linked to a base `StateDB` entry which defines the interaction + type (`StateKind.NAUTILUS_INITIALIZE`) and stores the session hierarchy. + + The purpose of storing this information is to allow the API to: + 1. Retrieve previously computed initialization results without re-running + the algorithm. + 2. Reconstruct the algorithm state if the function must be re-evaluated. Attributes: - id (int | None): Primary key of this NAUTILUS state entry. + state_id (int): Foreign key referencing the base `StateDB` entry. + + request (dict): Serialized request data passed to `navigator_init`. + response (dict): Serialized response returned by `navigator_init`. + objective_symbols (list[str]): Short symbolic names of the objectives. objective_long_names (list[str]): Descriptive names of the objectives. - units (list[str] | None): Units of the objectives, if defined. None if unitless. + units (list[str] | None): Units of the objectives if defined, otherwise None. is_maximized (list[bool]): Boolean flags indicating whether each objective is to be maximized (True) or minimized (False). + ideal (list[float]): Ideal objective values of the problem. nadir (list[float]): Nadir objective values of the problem. - lower_bounds (dict[str, list[float]]): Lower bounds of the reachable region per objective - across navigation steps. - upper_bounds (dict[str, list[float]]): Upper bounds of the reachable region per objective - across navigation steps. - preferences (dict[str, list[float]]): Preference values provided by the decision maker - for each navigation step. - bounds (dict[str, list[float]]): Bound preferences provided by the decision maker - for each navigation step. - total_steps (int): Total number of steps allowed in this NAUTILUS session. - current_step (int): Current navigation step index. - remaining_steps (int): Number of steps remaining in the navigation process. - reachable_solution (dict[str, float]): The objective values of the currently reachable solution. + + total_steps (int): Total number of navigation steps specified for the session. """ - __tablename__ = "nautilus_states" + __tablename__ = "nautilus_navigator_initialization_states" - id: int | None = Field(default=None, primary_key=True) + state_id: int = Field(foreign_key="state.id", primary_key=True) + + # Stored request/response + request: dict = Field(sa_column=Column(JSON)) + response: dict = Field(sa_column=Column(JSON)) # Problem meta objective_symbols: list[str] = Field(sa_column=Column(JSON)) @@ -49,15 +52,69 @@ class NautilusState(SQLModel, table=True): ideal: list[float] = Field(sa_column=Column(JSON)) nadir: list[float] = Field(sa_column=Column(JSON)) - # Navigation data - lower_bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) - upper_bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) - preferences: dict[str, list[float]] = Field(sa_column=Column(JSON)) - bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) - + # Navigator configuration total_steps: int + + +class NautilusNavigatorNavigationState(SQLModel, table=True): + """State storing the inputs and outputs of a NAUTILUS Navigator navigation step. + + This state corresponds to the execution of the `navigator_all_steps` function + in the NAUTILUS Navigator algorithm. Each navigation step produces a new + reachable solution and updated bounds based on the decision maker's + preferences. + + The state stores both the user input and the algorithm output so that: + 1. The navigation history can be inspected without recomputing results. + 2. The algorithm can be re-evaluated if needed. + + The state is linked to a base `StateDB` entry which defines the interaction + type (`StateKind.NAUTILUS_NAVIGATE`) and the parent state relationship. + + Attributes: + state_id (int): Foreign key referencing the base `StateDB` entry. + + request (dict): Serialized navigation request provided by the decision maker. + response (dict): Serialized response returned by the navigator algorithm. + + current_step (int): Current step index in the navigation process. + remaining_steps (int): Number of remaining navigation steps. + + preferences (dict[str, list[float]]): Preference values provided by the + decision maker for each objective. + + bounds (dict[str, list[float]]): Bound preferences provided by the + decision maker. + + lower_bounds (dict[str, list[float]]): Lower bounds of the reachable + objective region after the navigation step. + + upper_bounds (dict[str, list[float]]): Upper bounds of the reachable + objective region after the navigation step. + + reachable_solution (dict[str, float]): Objective values of the currently + reachable solution produced by the navigation step. + """ + + __tablename__ = "nautilus_navigator_navigation_states" + + state_id: int = Field(foreign_key="state.id", primary_key=True) + + # Stored request/response + request: dict = Field(sa_column=Column(JSON)) + response: dict = Field(sa_column=Column(JSON)) + + # Navigation progress current_step: int remaining_steps: int - # Final reachable solution + # Decision maker input + preferences: dict[str, list[float]] = Field(sa_column=Column(JSON)) + bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) + + # Reachable region bounds + lower_bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) + upper_bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) + + # Resulting solution reachable_solution: dict[str, float] = Field(sa_column=Column(JSON)) From f0d815a5bfa11db0f50ad69b60c417fe300aba4b Mon Sep 17 00:00:00 2001 From: Peter Bednarik Date: Wed, 11 Mar 2026 11:10:02 +0200 Subject: [PATCH 05/13] Removing useless parameters. --- desdeo/api/models/nautilus.py | 147 ++++++++++++--------------------- desdeo/api/schemas/nautilus.py | 10 ++- 2 files changed, 60 insertions(+), 97 deletions(-) diff --git a/desdeo/api/models/nautilus.py b/desdeo/api/models/nautilus.py index 80ec8abf4..070b37e3b 100644 --- a/desdeo/api/models/nautilus.py +++ b/desdeo/api/models/nautilus.py @@ -1,120 +1,79 @@ -from sqlmodel import JSON, Column, Field, SQLModel +from sqlalchemy import JSON, Column, ForeignKey, Integer +from sqlmodel import Field, SQLModel class NautilusNavigatorInitializationState(SQLModel, table=True): - """State storing the inputs and outputs of the NAUTILUS Navigator initialization. + """State representing initialization of a NAUTILUS Navigator session. - This state corresponds to the execution of the `navigator_init` function in the - NAUTILUS Navigator core algorithm. It stores both the request provided by the - user and the resulting initialization information returned by the algorithm. + This state corresponds to the execution of the `navigator_init` function + in the NAUTILUS Navigator algorithm. - The state is linked to a base `StateDB` entry which defines the interaction - type (`StateKind.NAUTILUS_INITIALIZE`) and stores the session hierarchy. + The initialization currently requires no explicit parameters from the API + since the required information (problem and solver) is obtained from the + surrounding application context. Therefore, this state only stores the + primary key linking it to the base `State` entry. - The purpose of storing this information is to allow the API to: - 1. Retrieve previously computed initialization results without re-running - the algorithm. - 2. Reconstruct the algorithm state if the function must be re-evaluated. - - Attributes: - state_id (int): Foreign key referencing the base `StateDB` entry. - - request (dict): Serialized request data passed to `navigator_init`. - response (dict): Serialized response returned by `navigator_init`. - - objective_symbols (list[str]): Short symbolic names of the objectives. - objective_long_names (list[str]): Descriptive names of the objectives. - units (list[str] | None): Units of the objectives if defined, otherwise None. - is_maximized (list[bool]): Boolean flags indicating whether each objective - is to be maximized (True) or minimized (False). - - ideal (list[float]): Ideal objective values of the problem. - nadir (list[float]): Nadir objective values of the problem. - - total_steps (int): Total number of navigation steps specified for the session. + Future versions may include additional fields such as + `non_dominated_solutions_id` if the algorithm later supports explicitly + supplying these. """ __tablename__ = "nautilus_navigator_initialization_states" - state_id: int = Field(foreign_key="state.id", primary_key=True) - - # Stored request/response - request: dict = Field(sa_column=Column(JSON)) - response: dict = Field(sa_column=Column(JSON)) - - # Problem meta - objective_symbols: list[str] = Field(sa_column=Column(JSON)) - objective_long_names: list[str] = Field(sa_column=Column(JSON)) - units: list[str] | None = Field(default=None, sa_column=Column(JSON)) - is_maximized: list[bool] = Field(sa_column=Column(JSON)) - - # Problem bounds - ideal: list[float] = Field(sa_column=Column(JSON)) - nadir: list[float] = Field(sa_column=Column(JSON)) - - # Navigator configuration - total_steps: int - + # Primary key referencing the base State entry. + state_id: int | None = Field( + sa_column=Column(Integer, ForeignKey("states.id", ondelete="CASCADE"), primary_key=True) + ) class NautilusNavigatorNavigationState(SQLModel, table=True): - """State storing the inputs and outputs of a NAUTILUS Navigator navigation step. + """State representing one execution of the NAUTILUS Navigator navigation step. - This state corresponds to the execution of the `navigator_all_steps` function - in the NAUTILUS Navigator algorithm. Each navigation step produces a new - reachable solution and updated bounds based on the decision maker's - preferences. + This state corresponds to a call to the `navigator_all_steps` function in + the NAUTILUS Navigator algorithm. - The state stores both the user input and the algorithm output so that: - 1. The navigation history can be inspected without recomputing results. - 2. The algorithm can be re-evaluated if needed. + The design follows the standard pattern used in DESDEO method states: - The state is linked to a base `StateDB` entry which defines the interaction - type (`StateKind.NAUTILUS_NAVIGATE`) and the parent state relationship. + - Fields correspond to the input arguments of the algorithm function + - A single field stores the result returned by the function - Attributes: - state_id (int): Foreign key referencing the base `StateDB` entry. + This allows the API to: + 1. Retrieve previously computed navigation results without + re-running the algorithm. + 2. Re-evaluate the algorithm if necessary, since the original + input parameters are stored. - request (dict): Serialized navigation request provided by the decision maker. - response (dict): Serialized response returned by the navigator algorithm. + Notes: + The parameters `problem` and `solver` are not stored in the state, + as they are provided by the surrounding application context. - current_step (int): Current step index in the navigation process. - remaining_steps (int): Number of remaining navigation steps. + Stored Inputs (arguments to `navigator_all_steps`): + steps_remaining: + Number of navigation steps to perform. - preferences (dict[str, list[float]]): Preference values provided by the - decision maker for each objective. + reference_point: + The reference point provided by the decision maker. - bounds (dict[str, list[float]]): Bound preferences provided by the - decision maker. + previous_responses: + The list of previous NAUTILUS responses representing the + navigation history up to this point. - lower_bounds (dict[str, list[float]]): Lower bounds of the reachable - objective region after the navigation step. + bounds: + Optional bounds specified by the decision maker. - upper_bounds (dict[str, list[float]]): Upper bounds of the reachable - objective region after the navigation step. - - reachable_solution (dict[str, float]): Objective values of the currently - reachable solution produced by the navigation step. + Stored Output: + navigator_results: + The list of responses returned by `navigator_all_steps`. + Each entry corresponds to one computed navigation step. """ __tablename__ = "nautilus_navigator_navigation_states" - state_id: int = Field(foreign_key="state.id", primary_key=True) - - # Stored request/response - request: dict = Field(sa_column=Column(JSON)) - response: dict = Field(sa_column=Column(JSON)) - - # Navigation progress - current_step: int - remaining_steps: int - - # Decision maker input - preferences: dict[str, list[float]] = Field(sa_column=Column(JSON)) - bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) - - # Reachable region bounds - lower_bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) - upper_bounds: dict[str, list[float]] = Field(sa_column=Column(JSON)) - - # Resulting solution - reachable_solution: dict[str, float] = Field(sa_column=Column(JSON)) + # Primary key referencing the base State entry. + state_id: int | None = Field( + sa_column=Column(Integer, ForeignKey("states.id", ondelete="CASCADE"), primary_key=True) + ) + steps_remaining: int + reference_point: dict[str, float] = Field(sa_column=Column(JSON)) + bounds: dict[str, float] | None = Field(default=None, sa_column=Column(JSON)) + previous_responses: list[dict] = Field(sa_column=Column(JSON)) + navigator_results: list[dict] = Field(sa_column=Column(JSON)) diff --git a/desdeo/api/schemas/nautilus.py b/desdeo/api/schemas/nautilus.py index 49543fa7d..58b4f4f78 100644 --- a/desdeo/api/schemas/nautilus.py +++ b/desdeo/api/schemas/nautilus.py @@ -14,7 +14,7 @@ class NautilusNavigateRequest(BaseModel): """Request to navigate a NAUTILUS Navigator session.""" problem_id: int = Field(..., description="The ID of the problem to navigate.") - preference: dict[str, float] = Field(..., description="The preference of the Decision Maker (DM) for each objective.") + reference_point: dict[str, float] = Field(..., description="Reference point provided by the decision maker.") bounds: dict[str, float] = Field(..., description="The bounds preference of the DM for each objective.") go_back_step: int = Field(..., description="The step index to go back in the navigation history.") steps_remaining: int = Field(..., description="The number of steps remaining in the navigation process.") @@ -52,8 +52,12 @@ class NautilusNavigateResponse(BaseModel): lower_bounds: dict[str, list[float]] = Field(..., description="Lower bounds of the reachable region per objective.") upper_bounds: dict[str, list[float]] = Field(..., description="Upper bounds of the reachable region per objective.") - preferences: dict[str, list[float]] = Field(..., description="Preferences used in each step per objective.") - bounds: dict[str, list[float]] = Field(..., description="Bounds used in each step per objective.") + + reference_point: dict[str, float] = Field(..., description="Preferences used in each step per objective.") + bounds: dict[str, float] | None = Field( + default=None, + description="Bounds used in each step per objective." + ) total_steps: int = Field(..., description="The total number of steps in the current navigation path.") reachable_solution: dict[str, float] = Field(..., description="The solution reached at the end of navigation.") From cfb27de8accf81d0471b9ba25399f11d2f37761b Mon Sep 17 00:00:00 2001 From: Peter Bednarik Date: Wed, 11 Mar 2026 11:14:39 +0200 Subject: [PATCH 06/13] Documentation fix. --- desdeo/api/schemas/nautilus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desdeo/api/schemas/nautilus.py b/desdeo/api/schemas/nautilus.py index 58b4f4f78..70e85cbf9 100644 --- a/desdeo/api/schemas/nautilus.py +++ b/desdeo/api/schemas/nautilus.py @@ -53,7 +53,7 @@ class NautilusNavigateResponse(BaseModel): lower_bounds: dict[str, list[float]] = Field(..., description="Lower bounds of the reachable region per objective.") upper_bounds: dict[str, list[float]] = Field(..., description="Upper bounds of the reachable region per objective.") - reference_point: dict[str, float] = Field(..., description="Preferences used in each step per objective.") + reference_point: dict[str, float] = Field(..., description="Reference point used in each step per objective.") bounds: dict[str, float] | None = Field( default=None, description="Bounds used in each step per objective." From 58ccf795a7368dc504c7fc19281dd96a3db98201 Mon Sep 17 00:00:00 2001 From: Peter Bednarik Date: Wed, 18 Mar 2026 10:10:51 +0200 Subject: [PATCH 07/13] Fixed. --- desdeo/api/schemas/__init__.py | 2 +- desdeo/api/schemas/nautilus.py | 62 +++++++++++++++++----------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/desdeo/api/schemas/__init__.py b/desdeo/api/schemas/__init__.py index 36827e27c..cb402ee08 100644 --- a/desdeo/api/schemas/__init__.py +++ b/desdeo/api/schemas/__init__.py @@ -2,5 +2,5 @@ NautilusInitialResponse, NautilusInitRequest, NautilusNavigateRequest, - NautilusResponse, + NautilusNavigateResponse, ) diff --git a/desdeo/api/schemas/nautilus.py b/desdeo/api/schemas/nautilus.py index 70e85cbf9..892912266 100644 --- a/desdeo/api/schemas/nautilus.py +++ b/desdeo/api/schemas/nautilus.py @@ -3,6 +3,21 @@ from pydantic import BaseModel, Field +class NautilusStep(BaseModel): + """Represents a single NAUTILUS step result.""" + + step_number: int + navigation_point: dict[str, float] + + lower_bounds: dict[str, float] + upper_bounds: dict[str, float] + + reachable_solution: dict[str, float] | None + reference_point: dict[str, float] | None + bounds: dict[str, float] | None + + distance_to_front: float + # Requests class NautilusInitRequest(BaseModel): """Request to initialize a NAUTILUS Navigator session for a specific problem.""" @@ -15,49 +30,34 @@ class NautilusNavigateRequest(BaseModel): problem_id: int = Field(..., description="The ID of the problem to navigate.") reference_point: dict[str, float] = Field(..., description="Reference point provided by the decision maker.") - bounds: dict[str, float] = Field(..., description="The bounds preference of the DM for each objective.") + bounds: dict[str, float] | None = Field( + default=None, + description="The bounds preference of the DM for each objective." + ) go_back_step: int = Field(..., description="The step index to go back in the navigation history.") steps_remaining: int = Field(..., description="The number of steps remaining in the navigation process.") # Responses class NautilusInitialResponse(BaseModel): - """Response returned by the NAUTILUS Navigator when initialized.""" + """Response returned by navigator_init.""" state_id: int = Field(..., description="The ID of this navigation state.") response_type: Literal["nautilus.initialize"] = "nautilus.initialize" - parent_state_id: int | None = Field(None, description="Parent state ID, if this is a child step.") + parent_state_id: int | None = Field(None, description="Parent state ID.") - objective_symbols: list[str] = Field(..., description="The symbols of the objectives.") - objective_long_names: list[str] = Field(..., description="Long/descriptive names of the objectives.") - units: list[str] | None = Field(None, description="The units of the objectives, empty if unitless.") - is_maximized: list[bool] = Field(..., description="Whether each objective is to be maximized.") - ideal: list[float] = Field(..., description="The ideal values of the objectives.") - nadir: list[float] = Field(..., description="The nadir values of the objectives.") - total_steps: int = Field(..., description="The total number of steps in this NAUTILUS session.") + navigation_point: dict[str, float] = Field(..., description="Initial navigation point (nadir point).") + lower_bounds: dict[str, float] = Field(..., description="Lower bounds of reachable region.") + upper_bounds: dict[str, float] = Field(..., description="Upper bounds of reachable region.") + + step_number: int = Field(..., description="Step number (always 0 at initialization).") + distance_to_front: float = Field(..., description="Distance to Pareto front.") class NautilusNavigateResponse(BaseModel): - """Response returned by the NAUTILUS Navigator during navigation (modern ENautilus style).""" + """Response returned by navigator_all_steps (list of computed navigation steps).""" - state_id: int = Field(..., description="The ID of this navigation state.") + state_id: int response_type: Literal["nautilus.navigate"] = "nautilus.navigate" - parent_state_id: int | None = Field(None, description="Parent state ID, if this is a child step.") - - objective_symbols: list[str] = Field(..., description="The symbols of the objectives.") - objective_long_names: list[str] = Field(..., description="Long/descriptive names of the objectives.") - units: list[str] | None = Field(None, description="The units of the objectives, empty if unitless.") - is_maximized: list[bool] = Field(..., description="Whether each objective is to be maximized.") - ideal: list[float] = Field(..., description="The ideal values of the objectives.") - nadir: list[float] = Field(..., description="The nadir values of the objectives.") - - lower_bounds: dict[str, list[float]] = Field(..., description="Lower bounds of the reachable region per objective.") - upper_bounds: dict[str, list[float]] = Field(..., description="Upper bounds of the reachable region per objective.") - - reference_point: dict[str, float] = Field(..., description="Reference point used in each step per objective.") - bounds: dict[str, float] | None = Field( - default=None, - description="Bounds used in each step per objective." - ) + parent_state_id: int | None = None - total_steps: int = Field(..., description="The total number of steps in the current navigation path.") - reachable_solution: dict[str, float] = Field(..., description="The solution reached at the end of navigation.") + steps: list[NautilusStep] From d0347f8e9def8f283ad188ff04a9ac8eb54cd56f Mon Sep 17 00:00:00 2001 From: Peter Bednarik Date: Mon, 23 Mar 2026 13:27:15 +0200 Subject: [PATCH 08/13] Endpoint for getting HTTP exception based on error code. --- desdeo/api/routers/generic.py | 32 ++++++++++++++++++++++++++++++++ desdeo/api/tests/test_routes.py | 19 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/desdeo/api/routers/generic.py b/desdeo/api/routers/generic.py index b7a5d01da..22467ea9f 100644 --- a/desdeo/api/routers/generic.py +++ b/desdeo/api/routers/generic.py @@ -194,3 +194,35 @@ def calculate_score_bands_from_objective_data( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Error calculating SCORE bands parameters: {e!r}", ) from e + + +@router.get("/debug/{httpcode}", tags=["debug"]) +async def trigger_error(httpcode: int): + """Debug endpoint to simulate HTTP errors. + + This endpoint takes a 3-digit HTTP status code as a path parameter + and raises the corresponding HTTPException. + + Example usage: + /method/generic/debug/404 + /method/generic/debug/500 + + Args: + httpcode (int): A valid HTTP status code (100-599) + + Raises: + HTTPException: Returns the HTTP error corresponding to `httpcode`. + + Reference: + https://fastapi.tiangolo.com/tutorial/handling-errors/ + """ + if httpcode < 100 or httpcode > 599: # noqa: PLR2004 + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid HTTP code. Must be between 100 and 599.", + ) + + raise HTTPException( + status_code=httpcode, + detail=f"Debug triggered HTTP {httpcode} error", + ) diff --git a/desdeo/api/tests/test_routes.py b/desdeo/api/tests/test_routes.py index 8c8095227..a0ba1e1c6 100644 --- a/desdeo/api/tests/test_routes.py +++ b/desdeo/api/tests/test_routes.py @@ -113,6 +113,25 @@ def test_refresh(client: TestClient): assert response_good.json()["access_token"] != response_refresh.json()["access_token"] +def test_debug_endpoint_valid_codes(client): + """Test that debug endpoint returns the requested HTTP error codes.""" + # Test 404 + response = client.get("/method/generic/debug/404") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Debug triggered HTTP 404 error" + + # Test 500 + response = client.get("/method/generic/debug/500") + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.json()["detail"] == "Debug triggered HTTP 500 error" + + +def test_debug_endpoint_invalid_code(client): + """Test that invalid HTTP codes return 400.""" + response = client.get("/method/generic/debug/999") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Invalid HTTP code" in response.json()["detail"] + def test_get_problem(client: TestClient): """Test fetching specific problems based on their id.""" From 9752a4ac0c0ecbee34525343884f76a47e28d446 Mon Sep 17 00:00:00 2001 From: Peter Bednarik Date: Mon, 23 Mar 2026 15:31:07 +0200 Subject: [PATCH 09/13] nautilus-navigator endpoints --- desdeo/api/routers/nautilus_navigator.py | 168 +++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 desdeo/api/routers/nautilus_navigator.py diff --git a/desdeo/api/routers/nautilus_navigator.py b/desdeo/api/routers/nautilus_navigator.py new file mode 100644 index 000000000..99661dba8 --- /dev/null +++ b/desdeo/api/routers/nautilus_navigator.py @@ -0,0 +1,168 @@ +"""New NAUTILUS Navigator endpoints (state-based design).""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from desdeo.api.db import get_db +from desdeo.api.db_models import Problem as ProblemInDB +from desdeo.api.models.nautilus import ( + NautilusNavigatorInitializationState, + NautilusNavigatorNavigationState, +) +from desdeo.api.routers.user_authentication import get_current_user +from desdeo.api.schema import User +from desdeo.api.schemas.nautilus import ( + NautilusInitialResponse, + NautilusInitRequest, + NautilusNavigateRequest, + NautilusNavigateResponse, + NautilusStep, +) +from desdeo.mcdm.nautilus_navigator import ( + NAUTILUS_Response, + navigator_all_steps, + navigator_init, +) +from desdeo.problem.schema import Problem + +router = APIRouter(prefix="/nautilus", tags=["NAUTILUS Navigator"]) + +def map_response_to_step(response: NAUTILUS_Response) -> NautilusStep: + """Helper function to map response to a specific step.""" + reachable_bounds = response.reachable_bounds or {} + + return NautilusStep( + step_number=response.step_number, + navigation_point=response.navigation_point, + lower_bounds=reachable_bounds.get("lower_bounds", {}), + upper_bounds=reachable_bounds.get("upper_bounds", {}), + reachable_solution=response.reachable_solution, + reference_point=response.reference_point, + bounds=response.bounds, + distance_to_front=response.distance_to_front, + ) + +@router.post("/initialize", response_model=NautilusInitialResponse) +def initialize_navigator( + request: NautilusInitRequest, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[Session, Depends(get_db)], +) -> NautilusInitialResponse: + """Initialize NAUTILUS Navigator.""" + # --- Validate problem --- + problem = db.query(ProblemInDB).filter(ProblemInDB.id == request.problem_id).first() + + if problem is None: + raise HTTPException(status_code=404, detail="Problem not found.") + if problem.owner != user.index and problem.owner is not None: + raise HTTPException(status_code=403, detail="Unauthorized.") + + try: + problem = Problem.model_validate(problem.value) + except ValidationError: + raise HTTPException(status_code=500, detail="Invalid problem format.") + + # --- Run algorithm --- + response = navigator_init(problem) + + # --- Create base state (assuming you have a generic State table) --- + base_state = NautilusNavigatorInitializationState() + db.add(base_state) + db.commit() + db.refresh(base_state) + + # --- Map bounds --- + reachable_bounds = response.reachable_bounds or {} + + return NautilusInitialResponse( + state_id=base_state.state_id, + parent_state_id=None, + navigation_point=response.navigation_point, + lower_bounds=reachable_bounds.get("lower_bounds", {}), + upper_bounds=reachable_bounds.get("upper_bounds", {}), + step_number=response.step_number, + distance_to_front=response.distance_to_front, + ) + +@router.post("/navigate", response_model=NautilusNavigateResponse) +def navigate_navigator( + request: NautilusNavigateRequest, + user: Annotated[User, Depends(get_current_user)], + db: Annotated[Session, Depends(get_db)], +) -> NautilusNavigateResponse: + """Perform NAUTILUS navigation steps.""" + # --- Validate problem --- + problem = db.query(ProblemInDB).filter(ProblemInDB.id == request.problem_id).first() + + if problem is None: + raise HTTPException(status_code=404, detail="Problem not found.") + if problem.owner != user.index and problem.owner is not None: + raise HTTPException(status_code=403, detail="Unauthorized.") + + try: + problem = Problem.model_validate(problem.value) + except ValidationError: + raise HTTPException(status_code=500, detail="Invalid problem format.") + + + # --- Determine parent state --- + parent_state = ( + db.query(NautilusNavigatorNavigationState) + .order_by(NautilusNavigatorNavigationState.state_id.desc()) + .first() + ) + parent_state_id = parent_state.state_id if parent_state else None + + # --- Extract previous responses --- + previous_responses: list[NAUTILUS_Response] = [] + current = parent_state + while current: + previous_responses = [ + NAUTILUS_Response.model_validate(r) for r in current.navigator_results + ] + previous_responses + if current.parent_state_id: + current = db.query(NautilusNavigatorNavigationState).filter( + NautilusNavigatorNavigationState.state_id == current.parent_state_id + ).first() + else: + current = None + + # --- Run algorithm --- + try: + new_responses = navigator_all_steps( + problem=problem, + steps_remaining=request.steps_remaining, + reference_point=request.reference_point, + previous_responses=previous_responses, + bounds=request.bounds, + ) + except IndexError as e: + raise HTTPException(status_code=400, detail="Bounds are too restrictive.") from e + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + # --- Store state --- + navigation_state = NautilusNavigatorNavigationState( + steps_remaining=request.steps_remaining, + reference_point=request.reference_point, + bounds=request.bounds, + previous_responses=[r.model_dump(mode="json") for r in previous_responses], + navigator_results=[r.model_dump(mode="json") for r in new_responses], + parent_state_id=parent_state_id, + ) + + db.add(navigation_state) + db.commit() + db.refresh(navigation_state) + + # --- Map response --- + steps = [map_response_to_step(r) for r in new_responses] + + return NautilusNavigateResponse( + state_id=navigation_state.state_id, + parent_state_id=parent_state_id, + steps=steps, + ) From 7858301c76a96606f895b2b6679a79452af92634 Mon Sep 17 00:00:00 2001 From: Peter Bednarik Date: Thu, 26 Mar 2026 16:37:41 +0200 Subject: [PATCH 10/13] Nautilus Navigator endpoints /initialize and /navigate + tests.v. 1.0 --- desdeo/api/app.py | 2 + desdeo/api/db.py | 3 + desdeo/api/models/generic_states.py | 2 +- .../{nautilus.py => nautilus_navigator.py} | 23 ++- desdeo/api/routers/nautilus_navigator.py | 107 +++++++++----- desdeo/api/tests/test_routes.py | 132 ++++++++++++++++++ 6 files changed, 226 insertions(+), 43 deletions(-) rename desdeo/api/models/{nautilus.py => nautilus_navigator.py} (81%) diff --git a/desdeo/api/app.py b/desdeo/api/app.py index c66d135cb..d3cee285d 100644 --- a/desdeo/api/app.py +++ b/desdeo/api/app.py @@ -8,6 +8,7 @@ emo, enautilus, generic, + nautilus_navigator, nimbus, problem, reference_point_method, @@ -38,6 +39,7 @@ app.include_router(gnimbus_routers.router) app.include_router(enautilus.router) app.include_router(gdm_score_bands_routers.router) +app.include_router(nautilus_navigator.router) origins = AuthConfig.cors_origins diff --git a/desdeo/api/db.py b/desdeo/api/db.py index 0b1188993..f9922af1d 100644 --- a/desdeo/api/db.py +++ b/desdeo/api/db.py @@ -1,10 +1,13 @@ """Database configuration file for the API.""" from sqlalchemy import event +from sqlalchemy.orm import declarative_base from sqlmodel import Session, create_engine from desdeo.api.config import DatabaseConfig, SettingsConfig +Base = declarative_base() + if SettingsConfig.debug: # debug and development stuff diff --git a/desdeo/api/models/generic_states.py b/desdeo/api/models/generic_states.py index 45b38bb34..c136fcda7 100644 --- a/desdeo/api/models/generic_states.py +++ b/desdeo/api/models/generic_states.py @@ -17,7 +17,7 @@ from desdeo.problem import Tensor, VariableType -from .nautilus import NautilusNavigatorInitializationState, NautilusNavigatorNavigationState +from .nautilus_navigator import NautilusNavigatorInitializationState, NautilusNavigatorNavigationState from .state import ( EMOFetchState, EMOIterateState, diff --git a/desdeo/api/models/nautilus.py b/desdeo/api/models/nautilus_navigator.py similarity index 81% rename from desdeo/api/models/nautilus.py rename to desdeo/api/models/nautilus_navigator.py index 070b37e3b..012efcc9f 100644 --- a/desdeo/api/models/nautilus.py +++ b/desdeo/api/models/nautilus_navigator.py @@ -21,8 +21,13 @@ class NautilusNavigatorInitializationState(SQLModel, table=True): __tablename__ = "nautilus_navigator_initialization_states" # Primary key referencing the base State entry. - state_id: int | None = Field( - sa_column=Column(Integer, ForeignKey("states.id", ondelete="CASCADE"), primary_key=True) + # state_id: int | None = Field( + # sa_column=Column(Integer, ForeignKey("states.id", ondelete="CASCADE"), primary_key=True) + # ) + id: int | None = Field( + default=None, + primary_key=True, + foreign_key="states.id", ) class NautilusNavigatorNavigationState(SQLModel, table=True): @@ -68,12 +73,20 @@ class NautilusNavigatorNavigationState(SQLModel, table=True): __tablename__ = "nautilus_navigator_navigation_states" - # Primary key referencing the base State entry. - state_id: int | None = Field( - sa_column=Column(Integer, ForeignKey("states.id", ondelete="CASCADE"), primary_key=True) + # Primary key referencing the base State entry + id: int | None = Field( + default=None, + primary_key=True, + foreign_key="states.id", ) + + # Foreign key referencing base State entry + # state_id: int | None = Field( + # sa_column=Column(Integer, ForeignKey("states.id", ondelete="CASCADE"), primary_key=True) + # ) steps_remaining: int reference_point: dict[str, float] = Field(sa_column=Column(JSON)) bounds: dict[str, float] | None = Field(default=None, sa_column=Column(JSON)) previous_responses: list[dict] = Field(sa_column=Column(JSON)) navigator_results: list[dict] = Field(sa_column=Column(JSON)) + parent_state_id: int | None = Field(default=None) diff --git a/desdeo/api/routers/nautilus_navigator.py b/desdeo/api/routers/nautilus_navigator.py index 99661dba8..bda76ad16 100644 --- a/desdeo/api/routers/nautilus_navigator.py +++ b/desdeo/api/routers/nautilus_navigator.py @@ -6,9 +6,10 @@ from pydantic import ValidationError from sqlalchemy.orm import Session -from desdeo.api.db import get_db +from desdeo.api.db import get_session from desdeo.api.db_models import Problem as ProblemInDB -from desdeo.api.models.nautilus import ( +from desdeo.api.models import StateDB +from desdeo.api.models.nautilus_navigator import ( NautilusNavigatorInitializationState, NautilusNavigatorNavigationState, ) @@ -49,36 +50,49 @@ def map_response_to_step(response: NAUTILUS_Response) -> NautilusStep: def initialize_navigator( request: NautilusInitRequest, user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_session)], ) -> NautilusInitialResponse: """Initialize NAUTILUS Navigator.""" # --- Validate problem --- - problem = db.query(ProblemInDB).filter(ProblemInDB.id == request.problem_id).first() + problem_db = db.query(ProblemInDB).filter(ProblemInDB.id == request.problem_id).first() - if problem is None: + if problem_db is None: raise HTTPException(status_code=404, detail="Problem not found.") - if problem.owner != user.index and problem.owner is not None: + if problem_db.owner != user.id and problem_db.owner is not None: raise HTTPException(status_code=403, detail="Unauthorized.") + # Remove forbidden fields manually for validation + raw_value = problem_db.value.copy() + raw_value.pop("is_convex_", None) + raw_value.pop("is_linear_", None) + raw_value.pop("is_twice_differentiable_", None) + try: - problem = Problem.model_validate(problem.value) + problem = Problem.model_validate(raw_value) + # problem = Problem.model_validate(problem.value) except ValidationError: - raise HTTPException(status_code=500, detail="Invalid problem format.") + raise HTTPException(status_code=500, detail="Invalid problem format.") # noqa: B904 # --- Run algorithm --- response = navigator_init(problem) - # --- Create base state (assuming you have a generic State table) --- - base_state = NautilusNavigatorInitializationState() - db.add(base_state) + # --- Create state properly via StateDB --- + substate = NautilusNavigatorInitializationState() + + state_row = StateDB.create( + database_session=db, + problem_id=problem_db.id, + state=substate, + session_id=user.active_session_id, # or None if not used + ) db.commit() - db.refresh(base_state) + db.refresh(state_row) # --- Map bounds --- reachable_bounds = response.reachable_bounds or {} return NautilusInitialResponse( - state_id=base_state.state_id, + state_id=state_row.base_state.id, parent_state_id=None, navigation_point=response.navigation_point, lower_bounds=reachable_bounds.get("lower_bounds", {}), @@ -91,46 +105,59 @@ def initialize_navigator( def navigate_navigator( request: NautilusNavigateRequest, user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_session)], ) -> NautilusNavigateResponse: """Perform NAUTILUS navigation steps.""" - # --- Validate problem --- - problem = db.query(ProblemInDB).filter(ProblemInDB.id == request.problem_id).first() + problem_db = db.query(ProblemInDB).filter(ProblemInDB.id == request.problem_id).first() - if problem is None: + if problem_db is None: raise HTTPException(status_code=404, detail="Problem not found.") - if problem.owner != user.index and problem.owner is not None: + if problem_db.owner != user.id and problem_db.owner is not None: raise HTTPException(status_code=403, detail="Unauthorized.") + raw_value = problem_db.value.copy() + raw_value.pop("is_convex_", None) + raw_value.pop("is_linear_", None) + raw_value.pop("is_twice_differentiable_", None) + try: - problem = Problem.model_validate(problem.value) + problem = Problem.model_validate(raw_value) except ValidationError: - raise HTTPException(status_code=500, detail="Invalid problem format.") - + raise HTTPException(status_code=500, detail="Invalid problem format.") # noqa: B904 - # --- Determine parent state --- - parent_state = ( + last_nav_state = ( db.query(NautilusNavigatorNavigationState) - .order_by(NautilusNavigatorNavigationState.state_id.desc()) + .order_by(NautilusNavigatorNavigationState.id.desc()) .first() ) - parent_state_id = parent_state.state_id if parent_state else None + parent_state_id = last_nav_state.id if last_nav_state else None - # --- Extract previous responses --- previous_responses: list[NAUTILUS_Response] = [] - current = parent_state + current = last_nav_state while current: previous_responses = [ NAUTILUS_Response.model_validate(r) for r in current.navigator_results ] + previous_responses - if current.parent_state_id: + if getattr(current, "parent_state_id", None): current = db.query(NautilusNavigatorNavigationState).filter( - NautilusNavigatorNavigationState.state_id == current.parent_state_id + NautilusNavigatorNavigationState.id == current.parent_state_id ).first() else: current = None - # --- Run algorithm --- + if not previous_responses: + previous_responses = [ + NAUTILUS_Response( + step_number=0, + navigation_point=request.reference_point, + reachable_solution=None, + reference_point=request.reference_point, + bounds=request.bounds, + distance_to_front=0.0, + reachable_bounds={"lower_bounds": {}, "upper_bounds": {}}, + ) + ] + try: new_responses = navigator_all_steps( problem=problem, @@ -142,10 +169,9 @@ def navigate_navigator( except IndexError as e: raise HTTPException(status_code=400, detail="Bounds are too restrictive.") from e except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) # noqa: B904 - # --- Store state --- - navigation_state = NautilusNavigatorNavigationState( + substate = NautilusNavigatorNavigationState( steps_remaining=request.steps_remaining, reference_point=request.reference_point, bounds=request.bounds, @@ -154,15 +180,22 @@ def navigate_navigator( parent_state_id=parent_state_id, ) - db.add(navigation_state) + state_row = StateDB.create( + database_session=db, + problem_id=problem_db.id, + state=substate, + session_id=user.active_session_id, + parent_id=parent_state_id, + ) + db.commit() - db.refresh(navigation_state) + db.refresh(state_row) + - # --- Map response --- steps = [map_response_to_step(r) for r in new_responses] return NautilusNavigateResponse( - state_id=navigation_state.state_id, + state_id=state_row.base_state.id, parent_state_id=parent_state_id, steps=steps, ) diff --git a/desdeo/api/tests/test_routes.py b/desdeo/api/tests/test_routes.py index a0ba1e1c6..9842f1f5f 100644 --- a/desdeo/api/tests/test_routes.py +++ b/desdeo/api/tests/test_routes.py @@ -6,6 +6,7 @@ from fastapi import status from fastapi.testclient import TestClient +from desdeo.api.db_models import Problem as ProblemInDB from desdeo.api.models import ( CreateSessionRequest, EMOFetchRequest, @@ -42,6 +43,7 @@ User, UserPublic, ) +from desdeo.api.models.generic_states import State from desdeo.api.models.nimbus import NIMBUSInitializationResponse from desdeo.api.routers.user_authentication import create_access_token from desdeo.emo.options.algorithms import rvea_options @@ -55,6 +57,136 @@ from .conftest import get_json, login, post_file_multipart, post_json from .test_models import compare_models +# --- NAUTILUS Navigator endpoint tests --- + +def test_initialize_navigator(client: TestClient, session_and_user: dict): + """Test /nautilus/initialize using the existing test user.""" + access_token = login(client) + user = session_and_user["user"] + session = session_and_user["session"] + + ProblemInDB.metadata.create_all(bind=session.bind, tables=[ProblemInDB.__table__]) + + # Create a test problem + problem = dtlz2(3, 2).model_dump() # raw dict + # Remove fields not allowed by Problem model + problem.pop("is_convex_", None) + problem.pop("is_linear_", None) + problem.pop("is_twice_differentiable_", None) + + problem_db = ProblemInDB( + owner=user.id, + name="test_problem", + kind="continuous", + obj_kind="analytical", + value=problem + ) + session.add(problem_db) + session.commit() + session.refresh(problem_db) + + response = client.post( + "/nautilus/initialize", + json={"problem_id": problem_db.id}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + # Assertions + assert response.status_code == 200 + data = response.json() + assert "state_id" in data + assert "navigation_point" in data + assert "step_number" in data + assert "lower_bounds" in data + assert "upper_bounds" in data + + # Clean up + session.delete(problem_db) + session.commit() + +def test_navigate_navigator(client: TestClient, session_and_user: dict): + """Test performing a NAUTILUS navigation step using the updated StateDB-based endpoint.""" + access_token = login(client) + user = session_and_user["user"] + session = session_and_user["session"] + + ProblemInDB.metadata.create_all(bind=session.bind, tables=[ProblemInDB.__table__]) + + + # --- Create a REAL problem --- + problem_obj = dtlz2(3, 2) # 3 variables, 2 objectives + problem_dict = problem_obj.model_dump() + + # Remove forbidden fields for Problem model + problem_dict.pop("is_convex_", None) + problem_dict.pop("is_linear_", None) + problem_dict.pop("is_twice_differentiable_", None) + + problem_db = ProblemInDB( + owner=user.id, + name="test_problem", + kind="continuous", + obj_kind="analytical", + value=problem_dict + ) + + session.add(problem_db) + session.commit() + session.refresh(problem_db) + + # --- Initialize first --- + init_response = client.post( + "/nautilus/initialize", + json={"problem_id": problem_db.id}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert init_response.status_code == 200 + init_data = init_response.json() + assert "state_id" in init_data + + # --- Prepare reference point and bounds for all objectives --- + # Use the actual objective names from the problem + validated_problem = Problem.model_validate(problem_dict) + objective_names = [obj.name for obj in validated_problem.objectives] + + ref_point = {name: 0.5 for name in objective_names} + bounds = {name: 1.0 for name in objective_names} # upper limit for each objective + + # --- Navigate --- + navigate_payload = { + "problem_id": problem_db.id, + "steps_remaining": 1, + "reference_point": ref_point, + "bounds": bounds, + "go_back_step": 0, + } + + response = client.post( + "/nautilus/navigate", + json=navigate_payload, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == 200 + + data = response.json() + assert "state_id" in data + assert "steps" in data + assert isinstance(data["steps"], list) + + if data["steps"]: + step = data["steps"][0] + assert "step_number" in step + assert "navigation_point" in step + assert "reachable_solution" in step + assert "lower_bounds" in step + assert "upper_bounds" in step + + # --- Clean up --- + session.delete(problem_db) + session.commit() + def test_user_login(client: TestClient): """Test that login works.""" From 2c3fc11c0478a55707baa80a85b25f59afe1f295 Mon Sep 17 00:00:00 2001 From: Giovanni Misitano Date: Wed, 8 Apr 2026 08:51:27 +0300 Subject: [PATCH 11/13] Web-API - Refactor NAUTILUS Navigator to follow E-NAUTILUS patterns. - Replace legacy manual auth/problem loading with SessionContextGuard. - Move request/response models from schemas/ to models/ as SQLModel. - Use Problem.from_problemdb() instead of manual fields. - Walk StateDB.parent for session-scoped parent chain traversal. - Remove parent_state_id from NautilusNavigatorNavigationState. - Delete desdeo/api/schemas/ (no longer needed). --- desdeo/api/models/__init__.py | 16 +++ desdeo/api/models/generic_states.py | 6 +- desdeo/api/models/nautilus_navigator.py | 70 +++++++-- desdeo/api/routers/nautilus_navigator.py | 176 +++++++++-------------- desdeo/api/routers/utils.py | 10 +- desdeo/api/schemas/__init__.py | 6 - desdeo/api/schemas/nautilus.py | 63 -------- 7 files changed, 154 insertions(+), 193 deletions(-) delete mode 100644 desdeo/api/schemas/__init__.py delete mode 100644 desdeo/api/schemas/nautilus.py diff --git a/desdeo/api/models/__init__.py b/desdeo/api/models/__init__.py index ba1726330..db9b1cd14 100644 --- a/desdeo/api/models/__init__.py +++ b/desdeo/api/models/__init__.py @@ -21,6 +21,13 @@ "ENautilusStepResponse", "ENautilusTreeNodeResponse", "ExtraFunctionDB", + "NautilusNavigatorInitializationState", + "NautilusNavigatorInitRequest", + "NautilusNavigatorInitResponse", + "NautilusNavigatorNavigateRequest", + "NautilusNavigatorNavigateResponse", + "NautilusNavigatorNavigationState", + "NautilusNavigatorStep", "ForestProblemMetaData", "GenericIntermediateSolutionResponse", "GNIMBUSOptimizationState", @@ -206,6 +213,15 @@ StateDB, UserSavedSolutionDB, ) +from .nautilus_navigator import ( + NautilusNavigatorInitializationState, + NautilusNavigatorInitRequest, + NautilusNavigatorInitResponse, + NautilusNavigatorNavigateRequest, + NautilusNavigatorNavigateResponse, + NautilusNavigatorNavigationState, + NautilusNavigatorStep, +) from .nimbus import ( NIMBUSClassificationRequest, NIMBUSClassificationResponse, diff --git a/desdeo/api/models/generic_states.py b/desdeo/api/models/generic_states.py index 0c1215ed1..aec8c2424 100644 --- a/desdeo/api/models/generic_states.py +++ b/desdeo/api/models/generic_states.py @@ -74,7 +74,6 @@ class StateKind(str, Enum): XNIMBUS_FINAL = "xnimbus.final" - class State(SQLModel, table=True): """The 'polymorphic' state to store method information.""" @@ -204,12 +203,11 @@ def state(self) -> SQLModel | None: StateKind.ENAUTILUS_STEP: ENautilusState, StateKind.ENAUTILUS_FINAL: ENautilusFinalState, StateKind.NAUTILUS_NAVIGATE: NautilusNavigatorNavigationState, - StateKind.NAUTILUS_INITIALIZE: NautilusNavigatorInitializationState + StateKind.NAUTILUS_INITIALIZE: NautilusNavigatorInitializationState, StateKind.XNIMBUS_SOLVE: NIMBUSClassificationState, StateKind.XNIMBUS_SAVE: NIMBUSSaveState, StateKind.XNIMBUS_INIT: NIMBUSInitializationState, StateKind.XNIMBUS_FINAL: NIMBUSFinalState, - } SUBSTATE_TO_KIND: dict[SQLModel, StateKind] = { @@ -229,7 +227,7 @@ def state(self) -> SQLModel | None: ENautilusState: StateKind.ENAUTILUS_STEP, ENautilusFinalState: StateKind.ENAUTILUS_FINAL, NautilusNavigatorNavigationState: StateKind.NAUTILUS_NAVIGATE, - NautilusNavigatorInitializationState: StateKind.NAUTILUS_INITIALIZE + NautilusNavigatorInitializationState: StateKind.NAUTILUS_INITIALIZE, } diff --git a/desdeo/api/models/nautilus_navigator.py b/desdeo/api/models/nautilus_navigator.py index 012efcc9f..4fb3f739b 100644 --- a/desdeo/api/models/nautilus_navigator.py +++ b/desdeo/api/models/nautilus_navigator.py @@ -1,4 +1,6 @@ -from sqlalchemy import JSON, Column, ForeignKey, Integer +"""Models specific to the NAUTILUS Navigator method.""" + +from sqlalchemy import JSON, Column from sqlmodel import Field, SQLModel @@ -20,16 +22,13 @@ class NautilusNavigatorInitializationState(SQLModel, table=True): __tablename__ = "nautilus_navigator_initialization_states" - # Primary key referencing the base State entry. - # state_id: int | None = Field( - # sa_column=Column(Integer, ForeignKey("states.id", ondelete="CASCADE"), primary_key=True) - # ) id: int | None = Field( default=None, primary_key=True, foreign_key="states.id", ) + class NautilusNavigatorNavigationState(SQLModel, table=True): """State representing one execution of the NAUTILUS Navigator navigation step. @@ -73,20 +72,71 @@ class NautilusNavigatorNavigationState(SQLModel, table=True): __tablename__ = "nautilus_navigator_navigation_states" - # Primary key referencing the base State entry id: int | None = Field( default=None, primary_key=True, foreign_key="states.id", ) - # Foreign key referencing base State entry - # state_id: int | None = Field( - # sa_column=Column(Integer, ForeignKey("states.id", ondelete="CASCADE"), primary_key=True) - # ) steps_remaining: int reference_point: dict[str, float] = Field(sa_column=Column(JSON)) bounds: dict[str, float] | None = Field(default=None, sa_column=Column(JSON)) previous_responses: list[dict] = Field(sa_column=Column(JSON)) navigator_results: list[dict] = Field(sa_column=Column(JSON)) + + +class NautilusNavigatorInitRequest(SQLModel): + """Request to initialize a NAUTILUS Navigator session.""" + + problem_id: int + session_id: int | None = Field(default=None) parent_state_id: int | None = Field(default=None) + + +class NautilusNavigatorNavigateRequest(SQLModel): + """Request to perform NAUTILUS Navigator navigation steps.""" + + problem_id: int + session_id: int | None = Field(default=None) + parent_state_id: int | None = Field(default=None) + reference_point: dict[str, float] = Field( + sa_column=Column(JSON), + description="Reference point provided by the decision maker.", + ) + bounds: dict[str, float] | None = Field( + default=None, + sa_column=Column(JSON), + description="The bounds preference of the DM for each objective.", + ) + steps_remaining: int = Field(description="The number of steps remaining in the navigation process.") + + +class NautilusNavigatorStep(SQLModel): + """A single NAUTILUS Navigator step result.""" + + step_number: int + navigation_point: dict[str, float] = Field(sa_column=Column(JSON)) + lower_bounds: dict[str, float] = Field(sa_column=Column(JSON)) + upper_bounds: dict[str, float] = Field(sa_column=Column(JSON)) + reachable_solution: dict[str, float] | None = Field(default=None, sa_column=Column(JSON)) + reference_point: dict[str, float] | None = Field(default=None, sa_column=Column(JSON)) + bounds: dict[str, float] | None = Field(default=None, sa_column=Column(JSON)) + distance_to_front: float + + +class NautilusNavigatorInitResponse(SQLModel): + """Response from NAUTILUS Navigator initialization.""" + + state_id: int | None = Field(description="The id of the state created by this initialization.") + navigation_point: dict[str, float] = Field(sa_column=Column(JSON), description="Initial navigation point.") + lower_bounds: dict[str, float] = Field(sa_column=Column(JSON), description="Lower bounds of reachable region.") + upper_bounds: dict[str, float] = Field(sa_column=Column(JSON), description="Upper bounds of reachable region.") + step_number: int = Field(description="Step number (always 0 at initialization).") + distance_to_front: float = Field(description="Distance to Pareto front.") + + +class NautilusNavigatorNavigateResponse(SQLModel): + """Response from NAUTILUS Navigator navigation.""" + + state_id: int | None = Field(description="The id of the state created by this navigation step.") + steps: list[NautilusNavigatorStep] = Field(description="The computed navigation steps.") diff --git a/desdeo/api/routers/nautilus_navigator.py b/desdeo/api/routers/nautilus_navigator.py index bda76ad16..467df7518 100644 --- a/desdeo/api/routers/nautilus_navigator.py +++ b/desdeo/api/routers/nautilus_navigator.py @@ -1,41 +1,38 @@ -"""New NAUTILUS Navigator endpoints (state-based design).""" +"""Defines end-points to access functionalities related to the NAUTILUS Navigator method.""" from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException -from pydantic import ValidationError -from sqlalchemy.orm import Session +from fastapi import APIRouter, Depends, HTTPException, status -from desdeo.api.db import get_session -from desdeo.api.db_models import Problem as ProblemInDB -from desdeo.api.models import StateDB +from desdeo.api.models import ( + NautilusNavigatorInitRequest, + NautilusNavigatorInitResponse, + NautilusNavigatorNavigateRequest, + NautilusNavigatorNavigateResponse, + NautilusNavigatorStep, + StateDB, +) from desdeo.api.models.nautilus_navigator import ( NautilusNavigatorInitializationState, NautilusNavigatorNavigationState, ) -from desdeo.api.routers.user_authentication import get_current_user -from desdeo.api.schema import User -from desdeo.api.schemas.nautilus import ( - NautilusInitialResponse, - NautilusInitRequest, - NautilusNavigateRequest, - NautilusNavigateResponse, - NautilusStep, -) from desdeo.mcdm.nautilus_navigator import ( NAUTILUS_Response, navigator_all_steps, navigator_init, ) -from desdeo.problem.schema import Problem +from desdeo.problem import Problem + +from .utils import ContextField, SessionContext, SessionContextGuard router = APIRouter(prefix="/nautilus", tags=["NAUTILUS Navigator"]) -def map_response_to_step(response: NAUTILUS_Response) -> NautilusStep: - """Helper function to map response to a specific step.""" + +def _map_response_to_step(response: NAUTILUS_Response) -> NautilusNavigatorStep: + """Map a NAUTILUS_Response to a NautilusNavigatorStep.""" reachable_bounds = response.reachable_bounds or {} - return NautilusStep( + return NautilusNavigatorStep( step_number=response.step_number, navigation_point=response.navigation_point, lower_bounds=reachable_bounds.get("lower_bounds", {}), @@ -46,54 +43,39 @@ def map_response_to_step(response: NAUTILUS_Response) -> NautilusStep: distance_to_front=response.distance_to_front, ) -@router.post("/initialize", response_model=NautilusInitialResponse) + +@router.post("/initialize") def initialize_navigator( - request: NautilusInitRequest, - user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_session)], -) -> NautilusInitialResponse: + request: NautilusNavigatorInitRequest, + context: Annotated[SessionContext, Depends(SessionContextGuard(require=[ContextField.PROBLEM]).post)], +) -> NautilusNavigatorInitResponse: """Initialize NAUTILUS Navigator.""" - # --- Validate problem --- - problem_db = db.query(ProblemInDB).filter(ProblemInDB.id == request.problem_id).first() - - if problem_db is None: - raise HTTPException(status_code=404, detail="Problem not found.") - if problem_db.owner != user.id and problem_db.owner is not None: - raise HTTPException(status_code=403, detail="Unauthorized.") - - # Remove forbidden fields manually for validation - raw_value = problem_db.value.copy() - raw_value.pop("is_convex_", None) - raw_value.pop("is_linear_", None) - raw_value.pop("is_twice_differentiable_", None) - - try: - problem = Problem.model_validate(raw_value) - # problem = Problem.model_validate(problem.value) - except ValidationError: - raise HTTPException(status_code=500, detail="Invalid problem format.") # noqa: B904 + db_session = context.db_session + problem_db = context.problem_db + problem = Problem.from_problemdb(problem_db) + interactive_session = context.interactive_session + parent_state = context.parent_state - # --- Run algorithm --- response = navigator_init(problem) - # --- Create state properly via StateDB --- substate = NautilusNavigatorInitializationState() - state_row = StateDB.create( - database_session=db, + state_db = StateDB.create( + database_session=db_session, problem_id=problem_db.id, + session_id=interactive_session.id if interactive_session is not None else None, + parent_id=parent_state.id if parent_state is not None else None, state=substate, - session_id=user.active_session_id, # or None if not used ) - db.commit() - db.refresh(state_row) - # --- Map bounds --- + db_session.add(state_db) + db_session.commit() + db_session.refresh(state_db) + reachable_bounds = response.reachable_bounds or {} - return NautilusInitialResponse( - state_id=state_row.base_state.id, - parent_state_id=None, + return NautilusNavigatorInitResponse( + state_id=state_db.id, navigation_point=response.navigation_point, lower_bounds=reachable_bounds.get("lower_bounds", {}), upper_bounds=reachable_bounds.get("upper_bounds", {}), @@ -101,49 +83,29 @@ def initialize_navigator( distance_to_front=response.distance_to_front, ) -@router.post("/navigate", response_model=NautilusNavigateResponse) + +@router.post("/navigate") def navigate_navigator( - request: NautilusNavigateRequest, - user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_session)], -) -> NautilusNavigateResponse: + request: NautilusNavigatorNavigateRequest, + context: Annotated[SessionContext, Depends(SessionContextGuard(require=[ContextField.PROBLEM]).post)], +) -> NautilusNavigatorNavigateResponse: """Perform NAUTILUS navigation steps.""" - problem_db = db.query(ProblemInDB).filter(ProblemInDB.id == request.problem_id).first() - - if problem_db is None: - raise HTTPException(status_code=404, detail="Problem not found.") - if problem_db.owner != user.id and problem_db.owner is not None: - raise HTTPException(status_code=403, detail="Unauthorized.") - - raw_value = problem_db.value.copy() - raw_value.pop("is_convex_", None) - raw_value.pop("is_linear_", None) - raw_value.pop("is_twice_differentiable_", None) - - try: - problem = Problem.model_validate(raw_value) - except ValidationError: - raise HTTPException(status_code=500, detail="Invalid problem format.") # noqa: B904 - - last_nav_state = ( - db.query(NautilusNavigatorNavigationState) - .order_by(NautilusNavigatorNavigationState.id.desc()) - .first() - ) - parent_state_id = last_nav_state.id if last_nav_state else None + db_session = context.db_session + problem_db = context.problem_db + problem = Problem.from_problemdb(problem_db) + interactive_session = context.interactive_session + parent_state = context.parent_state + # Reconstruct previous responses by walking the StateDB parent chain. previous_responses: list[NAUTILUS_Response] = [] - current = last_nav_state - while current: - previous_responses = [ - NAUTILUS_Response.model_validate(r) for r in current.navigator_results - ] + previous_responses - if getattr(current, "parent_state_id", None): - current = db.query(NautilusNavigatorNavigationState).filter( - NautilusNavigatorNavigationState.id == current.parent_state_id - ).first() - else: - current = None + current_state_db = parent_state + while current_state_db is not None: + sub = current_state_db.state + if isinstance(sub, NautilusNavigatorNavigationState): + previous_responses = [ + NAUTILUS_Response.model_validate(r) for r in sub.navigator_results + ] + previous_responses + current_state_db = current_state_db.parent if not previous_responses: previous_responses = [ @@ -167,9 +129,7 @@ def navigate_navigator( bounds=request.bounds, ) except IndexError as e: - raise HTTPException(status_code=400, detail="Bounds are too restrictive.") from e - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) # noqa: B904 + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bounds are too restrictive.") from e substate = NautilusNavigatorNavigationState( steps_remaining=request.steps_remaining, @@ -177,25 +137,23 @@ def navigate_navigator( bounds=request.bounds, previous_responses=[r.model_dump(mode="json") for r in previous_responses], navigator_results=[r.model_dump(mode="json") for r in new_responses], - parent_state_id=parent_state_id, ) - state_row = StateDB.create( - database_session=db, + state_db = StateDB.create( + database_session=db_session, problem_id=problem_db.id, state=substate, - session_id=user.active_session_id, - parent_id=parent_state_id, + session_id=interactive_session.id if interactive_session is not None else None, + parent_id=parent_state.id if parent_state is not None else None, ) - db.commit() - db.refresh(state_row) - + db_session.add(state_db) + db_session.commit() + db_session.refresh(state_db) - steps = [map_response_to_step(r) for r in new_responses] + steps = [_map_response_to_step(r) for r in new_responses] - return NautilusNavigateResponse( - state_id=state_row.base_state.id, - parent_state_id=parent_state_id, + return NautilusNavigatorNavigateResponse( + state_id=state_db.id, steps=steps, ) diff --git a/desdeo/api/routers/utils.py b/desdeo/api/routers/utils.py index 7f2caba46..118243aa8 100644 --- a/desdeo/api/routers/utils.py +++ b/desdeo/api/routers/utils.py @@ -15,6 +15,8 @@ from desdeo.api.models import ( ENautilusStepRequest, InteractiveSessionDB, + NautilusNavigatorInitRequest, + NautilusNavigatorNavigateRequest, ProblemDB, RPMSolveRequest, StateDB, @@ -23,7 +25,13 @@ from desdeo.api.models.session import CreateSessionRequest from desdeo.api.routers.user_authentication import get_current_user -RequestType = RPMSolveRequest | ENautilusStepRequest | CreateSessionRequest +RequestType = ( + RPMSolveRequest + | ENautilusStepRequest + | CreateSessionRequest + | NautilusNavigatorInitRequest + | NautilusNavigatorNavigateRequest +) def fetch_interactive_session( diff --git a/desdeo/api/schemas/__init__.py b/desdeo/api/schemas/__init__.py deleted file mode 100644 index cb402ee08..000000000 --- a/desdeo/api/schemas/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .nautilus import ( - NautilusInitialResponse, - NautilusInitRequest, - NautilusNavigateRequest, - NautilusNavigateResponse, -) diff --git a/desdeo/api/schemas/nautilus.py b/desdeo/api/schemas/nautilus.py deleted file mode 100644 index 892912266..000000000 --- a/desdeo/api/schemas/nautilus.py +++ /dev/null @@ -1,63 +0,0 @@ -from typing import Literal - -from pydantic import BaseModel, Field - - -class NautilusStep(BaseModel): - """Represents a single NAUTILUS step result.""" - - step_number: int - navigation_point: dict[str, float] - - lower_bounds: dict[str, float] - upper_bounds: dict[str, float] - - reachable_solution: dict[str, float] | None - reference_point: dict[str, float] | None - bounds: dict[str, float] | None - - distance_to_front: float - -# Requests -class NautilusInitRequest(BaseModel): - """Request to initialize a NAUTILUS Navigator session for a specific problem.""" - - problem_id: int = Field(..., description="The ID of the problem to navigate.") - total_steps: int = Field(100, description="The total number of steps in the NAUTILUS Navigator.") - -class NautilusNavigateRequest(BaseModel): - """Request to navigate a NAUTILUS Navigator session.""" - - problem_id: int = Field(..., description="The ID of the problem to navigate.") - reference_point: dict[str, float] = Field(..., description="Reference point provided by the decision maker.") - bounds: dict[str, float] | None = Field( - default=None, - description="The bounds preference of the DM for each objective." - ) - go_back_step: int = Field(..., description="The step index to go back in the navigation history.") - steps_remaining: int = Field(..., description="The number of steps remaining in the navigation process.") - -# Responses -class NautilusInitialResponse(BaseModel): - """Response returned by navigator_init.""" - - state_id: int = Field(..., description="The ID of this navigation state.") - response_type: Literal["nautilus.initialize"] = "nautilus.initialize" - parent_state_id: int | None = Field(None, description="Parent state ID.") - - navigation_point: dict[str, float] = Field(..., description="Initial navigation point (nadir point).") - - lower_bounds: dict[str, float] = Field(..., description="Lower bounds of reachable region.") - upper_bounds: dict[str, float] = Field(..., description="Upper bounds of reachable region.") - - step_number: int = Field(..., description="Step number (always 0 at initialization).") - distance_to_front: float = Field(..., description="Distance to Pareto front.") - -class NautilusNavigateResponse(BaseModel): - """Response returned by navigator_all_steps (list of computed navigation steps).""" - - state_id: int - response_type: Literal["nautilus.navigate"] = "nautilus.navigate" - parent_state_id: int | None = None - - steps: list[NautilusStep] From e15bd0eb368f73d4057cb9ea65e13a466dd30c72 Mon Sep 17 00:00:00 2001 From: Giovanni Misitano Date: Wed, 8 Apr 2026 13:44:14 +0300 Subject: [PATCH 12/13] Web-API - Added tests for NAUTILUS Navigator models and routes. --- desdeo/api/tests/test_nautilus_navigator.py | 218 ++++++++++++++++++++ desdeo/api/tests/test_routes.py | 63 +----- 2 files changed, 227 insertions(+), 54 deletions(-) create mode 100644 desdeo/api/tests/test_nautilus_navigator.py diff --git a/desdeo/api/tests/test_nautilus_navigator.py b/desdeo/api/tests/test_nautilus_navigator.py new file mode 100644 index 000000000..e55a86b2d --- /dev/null +++ b/desdeo/api/tests/test_nautilus_navigator.py @@ -0,0 +1,218 @@ +"""Tests related to NAUTILUS Navigator models and routes.""" + +import json + +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from desdeo.api.models import ( + NautilusNavigatorInitRequest, + NautilusNavigatorInitResponse, + NautilusNavigatorNavigateRequest, + NautilusNavigatorNavigateResponse, + ProblemDB, + User, +) + +from .conftest import login, post_json + + +def test_init_request_model(): + """Test NautilusNavigatorInitRequest model instantiation.""" + request = NautilusNavigatorInitRequest( + problem_id=1, + session_id=2, + parent_state_id=3, + ) + + assert request.problem_id == 1 + assert request.session_id == 2 + assert request.parent_state_id == 3 + + +def test_navigate_request_model(): + """Test NautilusNavigatorNavigateRequest model instantiation.""" + ref_point = {"f_1": 6.0, "f_2": 3.0, "f_3": 5.0, "f_4": -2.0, "f_5": 0.1} + bounds = {"f_1": 5.5, "f_2": 2.9} + + request = NautilusNavigatorNavigateRequest( + problem_id=10, + session_id=20, + parent_state_id=30, + reference_point=ref_point, + bounds=bounds, + steps_remaining=100, + ) + + assert request.problem_id == 10 + assert request.session_id == 20 + assert request.parent_state_id == 30 + assert request.reference_point == ref_point + assert request.bounds == bounds + assert request.steps_remaining == 100 + + +def test_initialize(client: TestClient, session_and_user: dict[str, Session | list[User]]): + """Test the NAUTILUS Navigator initialization endpoint.""" + session = session_and_user["session"] + access_token = login(client) + + problem_db = session.exec(select(ProblemDB).where(ProblemDB.name == "The river pollution problem")).first() + + request = NautilusNavigatorInitRequest(problem_id=problem_db.id) + + raw_response = post_json(client, "/nautilus/initialize", request.model_dump(), access_token) + + assert raw_response.status_code == 200 + + response = NautilusNavigatorInitResponse.model_validate(json.loads(raw_response.content)) + + assert response.state_id is not None + assert response.step_number == 0 + assert response.distance_to_front == 0.0 + + for obj in problem_db.objectives: + sym = obj.symbol + + # navigation_point should have all objective symbols + assert sym in response.navigation_point + + # bounds should be present and have all objective keys + assert sym in response.lower_bounds + assert sym in response.upper_bounds + + +def test_navigate_full_steps(client: TestClient, session_and_user: dict[str, Session | list[User]]): + """Test that a single navigate call produces all requested steps.""" + session = session_and_user["session"] + access_token = login(client) + + problem_db = session.exec(select(ProblemDB).where(ProblemDB.name == "The river pollution problem")).first() + + # Initialize + init_request = NautilusNavigatorInitRequest(problem_id=problem_db.id) + init_raw = post_json(client, "/nautilus/initialize", init_request.model_dump(), access_token) + assert init_raw.status_code == 200 + init_response = NautilusNavigatorInitResponse.model_validate(json.loads(init_raw.content)) + + # Navigate with a reference point, request all 100 remaining steps + steps_remaining = 10 + reference_point = {"f_1": 6.0, "f_2": 3.2, "f_3": 5.0, "f_4": -1.0, "f_5": 0.1} + + nav_request = NautilusNavigatorNavigateRequest( + problem_id=problem_db.id, + parent_state_id=init_response.state_id, + reference_point=reference_point, + steps_remaining=steps_remaining, + ) + + nav_raw = post_json(client, "/nautilus/navigate", nav_request.model_dump(), access_token) + assert nav_raw.status_code == 200 + + nav_response = NautilusNavigatorNavigateResponse.model_validate(json.loads(nav_raw.content)) + + assert nav_response.state_id is not None + assert len(nav_response.steps) == steps_remaining + + # Verify steps are ordered by step_number + step_numbers = [s.step_number for s in nav_response.steps] + assert step_numbers == sorted(step_numbers) + + # Each step should have navigation_point, bounds, and distance_to_front + for step in nav_response.steps: + for obj in problem_db.objectives: + sym = obj.symbol + assert sym in step.navigation_point + assert sym in step.lower_bounds + assert sym in step.upper_bounds + assert step.distance_to_front >= 0.0 + + +def test_navigate_preference_change(client: TestClient, session_and_user: dict[str, Session | list[User]]): + """Test that changing preferences mid-navigation recomputes only future steps. + + Scenario: navigate 10 steps, then "stop" at step 5 and provide new + preferences. The steps from the first navigation (1..5) should remain + unchanged in the first response; only the steps computed after the + preference change (from the second call) should differ. + """ + session = session_and_user["session"] + access_token = login(client) + + problem_db = session.exec(select(ProblemDB).where(ProblemDB.name == "The river pollution problem")).first() + + # Initialize + init_request = NautilusNavigatorInitRequest(problem_id=problem_db.id) + init_raw = post_json(client, "/nautilus/initialize", init_request.model_dump(), access_token) + assert init_raw.status_code == 200 + init_response = NautilusNavigatorInitResponse.model_validate(json.loads(init_raw.content)) + + # First navigation: compute all 10 steps with initial preferences + reference_point_1 = {"f_1": 6.0, "f_2": 3.2, "f_3": 5.0, "f_4": -1.0, "f_5": 0.1} + nav_request_1 = NautilusNavigatorNavigateRequest( + problem_id=problem_db.id, + parent_state_id=init_response.state_id, + reference_point=reference_point_1, + steps_remaining=10, + ) + + nav_raw_1 = post_json(client, "/nautilus/navigate", nav_request_1.model_dump(), access_token) + assert nav_raw_1.status_code == 200 + nav_response_1 = NautilusNavigatorNavigateResponse.model_validate(json.loads(nav_raw_1.content)) + + assert len(nav_response_1.steps) == 10 + + # The first 5 steps are "shown" to the DM via animation. + # The DM stops at step 5 and provides new preferences. + first_5_steps = nav_response_1.steps[:5] + + # Second navigation: DM changes preferences at step 5, 5 steps remaining + reference_point_2 = {"f_1": 5.0, "f_2": 3.0, "f_3": 7.0, "f_4": -5.0, "f_5": 0.2} + nav_request_2 = NautilusNavigatorNavigateRequest( + problem_id=problem_db.id, + parent_state_id=nav_response_1.state_id, + reference_point=reference_point_2, + steps_remaining=5, + ) + + nav_raw_2 = post_json(client, "/nautilus/navigate", nav_request_2.model_dump(), access_token) + assert nav_raw_2.status_code == 200 + nav_response_2 = NautilusNavigatorNavigateResponse.model_validate(json.loads(nav_raw_2.content)) + + assert nav_response_2.state_id is not None + assert len(nav_response_2.steps) == 5 + + # The first 5 steps (from the original navigation) should be unchanged, + # they were already shown to the DM and are historical. + # We verify that by checking the first navigation's steps 0..4 are still + # what we captured before the second call. + for i, step in enumerate(first_5_steps): + assert step.navigation_point == nav_response_1.steps[i].navigation_point + assert step.lower_bounds == nav_response_1.steps[i].lower_bounds + assert step.upper_bounds == nav_response_1.steps[i].upper_bounds + + # The new 5 steps should differ from the original steps 5..9 + # because the reference point changed + original_remaining = nav_response_1.steps[5:] + new_steps = nav_response_2.steps + + # At least some of the new steps should differ from the original remaining + # steps (different preferences → different navigation path) + differences_found = False + for orig, new in zip(original_remaining, new_steps, strict=True): + if orig.navigation_point != new.navigation_point: + differences_found = True + break + + assert differences_found, "New preferences should produce different navigation steps" + + +def test_initialize_invalid_problem(client: TestClient, session_and_user: dict[str, Session | list[User]]): + """Test that initializing with a non-existent problem returns an error.""" + access_token = login(client) + + request = NautilusNavigatorInitRequest(problem_id=99999) + + raw_response = post_json(client, "/nautilus/initialize", request.model_dump(), access_token) + + assert raw_response.status_code == 400 diff --git a/desdeo/api/tests/test_routes.py b/desdeo/api/tests/test_routes.py index f01f33770..a022d1eb4 100644 --- a/desdeo/api/tests/test_routes.py +++ b/desdeo/api/tests/test_routes.py @@ -6,7 +6,6 @@ from fastapi import status from fastapi.testclient import TestClient -from desdeo.api.db_models import Problem as ProblemInDB from desdeo.api.models import ( CreateSessionRequest, EMOFetchRequest, @@ -43,13 +42,11 @@ User, UserPublic, ) -from desdeo.api.models.generic_states import State from desdeo.api.models.nimbus import ( NIMBUSInitializationResponse, NIMBUSMultiplierRequest, NIMBUSMultiplierResponse, ) - from desdeo.api.routers.user_authentication import create_access_token from desdeo.emo.options.algorithms import rvea_options from desdeo.emo.options.templates import ReferencePointOptions @@ -62,7 +59,6 @@ from .conftest import get_json, login, post_file_multipart, post_json from .test_models import compare_models -# --- NAUTILUS Navigator endpoint tests --- def test_initialize_navigator(client: TestClient, session_and_user: dict): """Test /nautilus/initialize using the existing test user.""" @@ -70,22 +66,8 @@ def test_initialize_navigator(client: TestClient, session_and_user: dict): user = session_and_user["user"] session = session_and_user["session"] - ProblemInDB.metadata.create_all(bind=session.bind, tables=[ProblemInDB.__table__]) - - # Create a test problem - problem = dtlz2(3, 2).model_dump() # raw dict - # Remove fields not allowed by Problem model - problem.pop("is_convex_", None) - problem.pop("is_linear_", None) - problem.pop("is_twice_differentiable_", None) - - problem_db = ProblemInDB( - owner=user.id, - name="test_problem", - kind="continuous", - obj_kind="analytical", - value=problem - ) + # Create a test problem using ProblemDB (the SQLModel table used by SessionContextGuard) + problem_db = ProblemDB.from_problem(dtlz2(3, 2), user=user) session.add(problem_db) session.commit() session.refresh(problem_db) @@ -105,9 +87,6 @@ def test_initialize_navigator(client: TestClient, session_and_user: dict): assert "lower_bounds" in data assert "upper_bounds" in data - # Clean up - session.delete(problem_db) - session.commit() def test_navigate_navigator(client: TestClient, session_and_user: dict): """Test performing a NAUTILUS navigation step using the updated StateDB-based endpoint.""" @@ -115,31 +94,13 @@ def test_navigate_navigator(client: TestClient, session_and_user: dict): user = session_and_user["user"] session = session_and_user["session"] - ProblemInDB.metadata.create_all(bind=session.bind, tables=[ProblemInDB.__table__]) - - - # --- Create a REAL problem --- + # Create a test problem using ProblemDB (the SQLModel table used by SessionContextGuard) problem_obj = dtlz2(3, 2) # 3 variables, 2 objectives - problem_dict = problem_obj.model_dump() - - # Remove forbidden fields for Problem model - problem_dict.pop("is_convex_", None) - problem_dict.pop("is_linear_", None) - problem_dict.pop("is_twice_differentiable_", None) - - problem_db = ProblemInDB( - owner=user.id, - name="test_problem", - kind="continuous", - obj_kind="analytical", - value=problem_dict - ) - + problem_db = ProblemDB.from_problem(problem_obj, user=user) session.add(problem_db) session.commit() session.refresh(problem_db) - # --- Initialize first --- init_response = client.post( "/nautilus/initialize", json={"problem_id": problem_db.id}, @@ -150,21 +111,18 @@ def test_navigate_navigator(client: TestClient, session_and_user: dict): init_data = init_response.json() assert "state_id" in init_data - # --- Prepare reference point and bounds for all objectives --- - # Use the actual objective names from the problem - validated_problem = Problem.model_validate(problem_dict) - objective_names = [obj.name for obj in validated_problem.objectives] + objective_symbols = [obj.symbol for obj in problem_obj.objectives] - ref_point = {name: 0.5 for name in objective_names} - bounds = {name: 1.0 for name in objective_names} # upper limit for each objective + ref_point = dict.fromkeys(objective_symbols, 0.5) + bounds = dict.fromkeys(objective_symbols, 1.0) # --- Navigate --- navigate_payload = { "problem_id": problem_db.id, + "parent_state_id": init_data["state_id"], "steps_remaining": 1, "reference_point": ref_point, "bounds": bounds, - "go_back_step": 0, } response = client.post( @@ -188,10 +146,6 @@ def test_navigate_navigator(client: TestClient, session_and_user: dict): assert "lower_bounds" in step assert "upper_bounds" in step - # --- Clean up --- - session.delete(problem_db) - session.commit() - def test_user_login(client: TestClient): """Test that login works.""" @@ -250,6 +204,7 @@ def test_refresh(client: TestClient): assert response_good.json()["access_token"] != response_refresh.json()["access_token"] + def test_debug_endpoint_valid_codes(client): """Test that debug endpoint returns the requested HTTP error codes.""" # Test 404 From e80a42e2d8476caa0006569b27aa2dc634fc01f2 Mon Sep 17 00:00:00 2001 From: Giovanni Misitano Date: Wed, 8 Apr 2026 13:57:48 +0300 Subject: [PATCH 13/13] Web-GUI - Generated new Orval endpoints for Nautilus Navigator. --- webui/src/lib/gen/endpoints/DESDEOFastAPI.ts | 309 +++++++++++++++++- .../src/lib/gen/endpoints/DESDEOFastAPIzod.ts | 161 +++++++++ 2 files changed, 467 insertions(+), 3 deletions(-) diff --git a/webui/src/lib/gen/endpoints/DESDEOFastAPI.ts b/webui/src/lib/gen/endpoints/DESDEOFastAPI.ts index 9b42465c4..1f2f32c46 100644 --- a/webui/src/lib/gen/endpoints/DESDEOFastAPI.ts +++ b/webui/src/lib/gen/endpoints/DESDEOFastAPI.ts @@ -86,9 +86,49 @@ export interface CreateSessionRequest { info?: string | null; } +/** + * Request to initialize a NAUTILUS Navigator session. + */ +export interface NautilusNavigatorInitRequest { + problem_id: number; + session_id?: number | null; + parent_state_id?: number | null; +} + +/** + * Reference point provided by the decision maker. + */ +export type NautilusNavigatorNavigateRequestReferencePoint = { [key: string]: number }; + +/** + * The bounds preference of the DM for each objective. + */ +export type NautilusNavigatorNavigateRequestBounds = { [key: string]: number } | null; + +/** + * Request to perform NAUTILUS Navigator navigation steps. + */ +export interface NautilusNavigatorNavigateRequest { + problem_id: number; + session_id?: number | null; + parent_state_id?: number | null; + /** Reference point provided by the decision maker. */ + reference_point: NautilusNavigatorNavigateRequestReferencePoint; + /** The bounds preference of the DM for each objective. */ + bounds?: NautilusNavigatorNavigateRequestBounds; + /** The number of steps remaining in the navigation process. */ + steps_remaining: number; +} + export interface BodyAddProblemJsonProblemAddJsonPost { json_file: Blob; - request?: RPMSolveRequest | ENautilusStepRequest | CreateSessionRequest | null; + request?: + | RPMSolveRequest + | ENautilusStepRequest + | CreateSessionRequest + | NautilusNavigatorInitRequest + | NautilusNavigatorNavigateRequest + | null; } export interface BodyLoginLoginPost { @@ -1360,6 +1400,75 @@ export interface NIMBUSSaveResponse { state_id: number | null; } +/** + * Initial navigation point. + */ +export type NautilusNavigatorInitResponseNavigationPoint = { [key: string]: number }; + +/** + * Lower bounds of reachable region. + */ +export type NautilusNavigatorInitResponseLowerBounds = { [key: string]: number }; + +/** + * Upper bounds of reachable region. + */ +export type NautilusNavigatorInitResponseUpperBounds = { [key: string]: number }; + +/** + * Response from NAUTILUS Navigator initialization. + */ +export interface NautilusNavigatorInitResponse { + /** The id of the state created by this initialization. */ + state_id: number | null; + /** Initial navigation point. */ + navigation_point: NautilusNavigatorInitResponseNavigationPoint; + /** Lower bounds of reachable region. */ + lower_bounds: NautilusNavigatorInitResponseLowerBounds; + /** Upper bounds of reachable region. */ + upper_bounds: NautilusNavigatorInitResponseUpperBounds; + /** Step number (always 0 at initialization). */ + step_number: number; + /** Distance to Pareto front. */ + distance_to_front: number; +} + +export type NautilusNavigatorStepNavigationPoint = { [key: string]: number }; + +export type NautilusNavigatorStepLowerBounds = { [key: string]: number }; + +export type NautilusNavigatorStepUpperBounds = { [key: string]: number }; + +export type NautilusNavigatorStepReachableSolution = { [key: string]: number } | null; + +export type NautilusNavigatorStepReferencePoint = { [key: string]: number } | null; + +export type NautilusNavigatorStepBounds = { [key: string]: number } | null; + +/** + * A single NAUTILUS Navigator step result. + */ +export interface NautilusNavigatorStep { + step_number: number; + navigation_point: NautilusNavigatorStepNavigationPoint; + lower_bounds: NautilusNavigatorStepLowerBounds; + upper_bounds: NautilusNavigatorStepUpperBounds; + reachable_solution?: NautilusNavigatorStepReachableSolution; + reference_point?: NautilusNavigatorStepReferencePoint; + bounds?: NautilusNavigatorStepBounds; + distance_to_front: number; +} + +/** + * Response from NAUTILUS Navigator navigation. + */ +export interface NautilusNavigatorNavigateResponse { + /** The id of the state created by this navigation step. */ + state_id: number | null; + /** The computed navigation steps. */ + steps: NautilusNavigatorStep[]; +} + /** * An enumerator for supported objective function types. */ @@ -2009,6 +2118,14 @@ export type ConfigureGdmGdmScoreBandsConfigurePostParams = { group_id: number; }; +export type InitializeNavigatorNautilusInitializePostParams = { + problem_id?: number | null; +}; + +export type NavigateNavigatorNautilusNavigatePostParams = { + problem_id?: number | null; +}; + /** * Return all users with the decision maker role. Requires analyst or admin. @@ -2662,10 +2779,12 @@ export const getAddProblemProblemAddPostUrl = (params?: AddProblemProblemAddPost }; export const addProblemProblemAddPost = async ( - rPMSolveRequestENautilusStepRequestCreateSessionRequestNull: + rPMSolveRequestENautilusStepRequestCreateSessionRequestNautilusNavigatorInitRequestNautilusNavigatorNavigateRequestNull: | RPMSolveRequest | ENautilusStepRequest | CreateSessionRequest + | NautilusNavigatorInitRequest + | NautilusNavigatorNavigateRequest | null, params?: AddProblemProblemAddPostParams, options?: RequestInit @@ -2674,7 +2793,9 @@ export const addProblemProblemAddPost = async ( ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(rPMSolveRequestENautilusStepRequestCreateSessionRequestNull) + body: JSON.stringify( + rPMSolveRequestENautilusStepRequestCreateSessionRequestNautilusNavigatorInitRequestNautilusNavigatorNavigateRequestNull + ) }); }; @@ -4664,6 +4785,66 @@ export const calculateScoreBandsFromObjectiveDataMethodGenericScoreBandsObjDataP ); }; +/** + * Debug endpoint to simulate HTTP errors. + +This endpoint takes a 3-digit HTTP status code as a path parameter +and raises the corresponding HTTPException. + +Example usage: + /method/generic/debug/404 + /method/generic/debug/500 + +Args: + httpcode (int): A valid HTTP status code (100-599) + +Raises: + HTTPException: Returns the HTTP error corresponding to `httpcode`. + +Reference: + https://fastapi.tiangolo.com/tutorial/handling-errors/ + * @summary Trigger Error + */ +export type triggerErrorMethodGenericDebugHttpcodeGetResponse200 = { + data: unknown; + status: 200; +}; + +export type triggerErrorMethodGenericDebugHttpcodeGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type triggerErrorMethodGenericDebugHttpcodeGetResponseSuccess = + triggerErrorMethodGenericDebugHttpcodeGetResponse200 & { + headers: Headers; + }; +export type triggerErrorMethodGenericDebugHttpcodeGetResponseError = + triggerErrorMethodGenericDebugHttpcodeGetResponse422 & { + headers: Headers; + }; + +export type triggerErrorMethodGenericDebugHttpcodeGetResponse = + | triggerErrorMethodGenericDebugHttpcodeGetResponseSuccess + | triggerErrorMethodGenericDebugHttpcodeGetResponseError; + +export const getTriggerErrorMethodGenericDebugHttpcodeGetUrl = (httpcode: number) => { + return `http://localhost:8000/method/generic/debug/${httpcode}`; +}; + +export const triggerErrorMethodGenericDebugHttpcodeGet = async ( + httpcode: number, + options?: RequestInit +): Promise => { + return customFetch( + getTriggerErrorMethodGenericDebugHttpcodeGetUrl(httpcode), + { + ...options, + method: 'GET' + } + ); +}; + /** * Request and receive the Utopia map corresponding to the decision variables sent. @@ -6121,6 +6302,128 @@ export const configureGdmGdmScoreBandsConfigurePost = async ( ); }; +/** + * Initialize NAUTILUS Navigator. + * @summary Initialize Navigator + */ +export type initializeNavigatorNautilusInitializePostResponse200 = { + data: NautilusNavigatorInitResponse; + status: 200; +}; + +export type initializeNavigatorNautilusInitializePostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type initializeNavigatorNautilusInitializePostResponseSuccess = + initializeNavigatorNautilusInitializePostResponse200 & { + headers: Headers; + }; +export type initializeNavigatorNautilusInitializePostResponseError = + initializeNavigatorNautilusInitializePostResponse422 & { + headers: Headers; + }; + +export type initializeNavigatorNautilusInitializePostResponse = + | initializeNavigatorNautilusInitializePostResponseSuccess + | initializeNavigatorNautilusInitializePostResponseError; + +export const getInitializeNavigatorNautilusInitializePostUrl = ( + params?: InitializeNavigatorNautilusInitializePostParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `http://localhost:8000/nautilus/initialize?${stringifiedParams}` + : `http://localhost:8000/nautilus/initialize`; +}; + +export const initializeNavigatorNautilusInitializePost = async ( + nautilusNavigatorInitRequest: NautilusNavigatorInitRequest, + params?: InitializeNavigatorNautilusInitializePostParams, + options?: RequestInit +): Promise => { + return customFetch( + getInitializeNavigatorNautilusInitializePostUrl(params), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(nautilusNavigatorInitRequest) + } + ); +}; + +/** + * Perform NAUTILUS navigation steps. + * @summary Navigate Navigator + */ +export type navigateNavigatorNautilusNavigatePostResponse200 = { + data: NautilusNavigatorNavigateResponse; + status: 200; +}; + +export type navigateNavigatorNautilusNavigatePostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type navigateNavigatorNautilusNavigatePostResponseSuccess = + navigateNavigatorNautilusNavigatePostResponse200 & { + headers: Headers; + }; +export type navigateNavigatorNautilusNavigatePostResponseError = + navigateNavigatorNautilusNavigatePostResponse422 & { + headers: Headers; + }; + +export type navigateNavigatorNautilusNavigatePostResponse = + | navigateNavigatorNautilusNavigatePostResponseSuccess + | navigateNavigatorNautilusNavigatePostResponseError; + +export const getNavigateNavigatorNautilusNavigatePostUrl = ( + params?: NavigateNavigatorNautilusNavigatePostParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `http://localhost:8000/nautilus/navigate?${stringifiedParams}` + : `http://localhost:8000/nautilus/navigate`; +}; + +export const navigateNavigatorNautilusNavigatePost = async ( + nautilusNavigatorNavigateRequest: NautilusNavigatorNavigateRequest, + params?: NavigateNavigatorNautilusNavigatePostParams, + options?: RequestInit +): Promise => { + return customFetch( + getNavigateNavigatorNautilusNavigatePostUrl(params), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(nautilusNavigatorNavigateRequest) + } + ); +}; + /** * @summary Health */ diff --git a/webui/src/lib/gen/endpoints/DESDEOFastAPIzod.ts b/webui/src/lib/gen/endpoints/DESDEOFastAPIzod.ts index 9a2e1eed8..352c8190d 100644 --- a/webui/src/lib/gen/endpoints/DESDEOFastAPIzod.ts +++ b/webui/src/lib/gen/endpoints/DESDEOFastAPIzod.ts @@ -1666,6 +1666,30 @@ export const AddProblemProblemAddPostBody = zod.union([ info: zod.union([zod.string(), zod.null()]).optional() }) .describe('Model of the request to create a new session.'), + zod + .object({ + problem_id: zod.number(), + session_id: zod.union([zod.number(), zod.null()]).optional(), + parent_state_id: zod.union([zod.number(), zod.null()]).optional() + }) + .describe('Request to initialize a NAUTILUS Navigator session.'), + zod + .object({ + problem_id: zod.number(), + session_id: zod.union([zod.number(), zod.null()]).optional(), + parent_state_id: zod.union([zod.number(), zod.null()]).optional(), + reference_point: zod + .record(zod.string(), zod.number()) + .describe('Reference point provided by the decision maker.'), + bounds: zod + .union([zod.record(zod.string(), zod.number()), zod.null()]) + .optional() + .describe('The bounds preference of the DM for each objective.'), + steps_remaining: zod + .number() + .describe('The number of steps remaining in the navigation process.') + }) + .describe('Request to perform NAUTILUS Navigator navigation steps.'), zod.null() ]); @@ -2374,6 +2398,30 @@ export const AddProblemJsonProblemAddJsonPostBody = zod.object({ info: zod.union([zod.string(), zod.null()]).optional() }) .describe('Model of the request to create a new session.'), + zod + .object({ + problem_id: zod.number(), + session_id: zod.union([zod.number(), zod.null()]).optional(), + parent_state_id: zod.union([zod.number(), zod.null()]).optional() + }) + .describe('Request to initialize a NAUTILUS Navigator session.'), + zod + .object({ + problem_id: zod.number(), + session_id: zod.union([zod.number(), zod.null()]).optional(), + parent_state_id: zod.union([zod.number(), zod.null()]).optional(), + reference_point: zod + .record(zod.string(), zod.number()) + .describe('Reference point provided by the decision maker.'), + bounds: zod + .union([zod.record(zod.string(), zod.number()), zod.null()]) + .optional() + .describe('The bounds preference of the DM for each objective.'), + steps_remaining: zod + .number() + .describe('The number of steps remaining in the navigation process.') + }) + .describe('Request to perform NAUTILUS Navigator navigation steps.'), zod.null() ]) .optional() @@ -6325,6 +6373,32 @@ export const CalculateScoreBandsFromObjectiveDataMethodGenericScoreBandsObjDataP }) .describe('Model of the response containing SCORE bands parameters.'); +/** + * Debug endpoint to simulate HTTP errors. + +This endpoint takes a 3-digit HTTP status code as a path parameter +and raises the corresponding HTTPException. + +Example usage: + /method/generic/debug/404 + /method/generic/debug/500 + +Args: + httpcode (int): A valid HTTP status code (100-599) + +Raises: + HTTPException: Returns the HTTP error corresponding to `httpcode`. + +Reference: + https://fastapi.tiangolo.com/tutorial/handling-errors/ + * @summary Trigger Error + */ +export const TriggerErrorMethodGenericDebugHttpcodeGetParams = zod.object({ + httpcode: zod.number() +}); + +export const TriggerErrorMethodGenericDebugHttpcodeGetResponse = zod.unknown(); + /** * Request and receive the Utopia map corresponding to the decision variables sent. @@ -8555,6 +8629,93 @@ export const ConfigureGdmGdmScoreBandsConfigurePostBody = zod export const ConfigureGdmGdmScoreBandsConfigurePostResponse = zod.unknown(); +/** + * Initialize NAUTILUS Navigator. + * @summary Initialize Navigator + */ +export const InitializeNavigatorNautilusInitializePostQueryParams = zod.object({ + problem_id: zod.union([zod.number(), zod.null()]).optional() +}); + +export const InitializeNavigatorNautilusInitializePostBody = zod + .object({ + problem_id: zod.number(), + session_id: zod.union([zod.number(), zod.null()]).optional(), + parent_state_id: zod.union([zod.number(), zod.null()]).optional() + }) + .describe('Request to initialize a NAUTILUS Navigator session.'); + +export const InitializeNavigatorNautilusInitializePostResponse = zod + .object({ + state_id: zod + .union([zod.number(), zod.null()]) + .describe('The id of the state created by this initialization.'), + navigation_point: zod.record(zod.string(), zod.number()).describe('Initial navigation point.'), + lower_bounds: zod + .record(zod.string(), zod.number()) + .describe('Lower bounds of reachable region.'), + upper_bounds: zod + .record(zod.string(), zod.number()) + .describe('Upper bounds of reachable region.'), + step_number: zod.number().describe('Step number (always 0 at initialization).'), + distance_to_front: zod.number().describe('Distance to Pareto front.') + }) + .describe('Response from NAUTILUS Navigator initialization.'); + +/** + * Perform NAUTILUS navigation steps. + * @summary Navigate Navigator + */ +export const NavigateNavigatorNautilusNavigatePostQueryParams = zod.object({ + problem_id: zod.union([zod.number(), zod.null()]).optional() +}); + +export const NavigateNavigatorNautilusNavigatePostBody = zod + .object({ + problem_id: zod.number(), + session_id: zod.union([zod.number(), zod.null()]).optional(), + parent_state_id: zod.union([zod.number(), zod.null()]).optional(), + reference_point: zod + .record(zod.string(), zod.number()) + .describe('Reference point provided by the decision maker.'), + bounds: zod + .union([zod.record(zod.string(), zod.number()), zod.null()]) + .optional() + .describe('The bounds preference of the DM for each objective.'), + steps_remaining: zod + .number() + .describe('The number of steps remaining in the navigation process.') + }) + .describe('Request to perform NAUTILUS Navigator navigation steps.'); + +export const NavigateNavigatorNautilusNavigatePostResponse = zod + .object({ + state_id: zod + .union([zod.number(), zod.null()]) + .describe('The id of the state created by this navigation step.'), + steps: zod + .array( + zod + .object({ + step_number: zod.number(), + navigation_point: zod.record(zod.string(), zod.number()), + lower_bounds: zod.record(zod.string(), zod.number()), + upper_bounds: zod.record(zod.string(), zod.number()), + reachable_solution: zod + .union([zod.record(zod.string(), zod.number()), zod.null()]) + .optional(), + reference_point: zod + .union([zod.record(zod.string(), zod.number()), zod.null()]) + .optional(), + bounds: zod.union([zod.record(zod.string(), zod.number()), zod.null()]).optional(), + distance_to_front: zod.number() + }) + .describe('A single NAUTILUS Navigator step result.') + ) + .describe('The computed navigation steps.') + }) + .describe('Response from NAUTILUS Navigator navigation.'); + /** * @summary Health */