diff --git a/desdeo/api/app.py b/desdeo/api/app.py index e3709a96..a3c8d89a 100644 --- a/desdeo/api/app.py +++ b/desdeo/api/app.py @@ -7,6 +7,7 @@ from desdeo.api.routers import ( enautilus, generic, + nautilus_navigator, nimbus, problem, reference_point_method, @@ -41,6 +42,7 @@ app.include_router(enautilus.router) app.include_router(site_selection.router) app.include_router(gdm_score_bands_routers.router) +app.include_router(nautilus_navigator.router) @app.get("/health") diff --git a/desdeo/api/db.py b/desdeo/api/db.py index 0b118899..f9922af1 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/__init__.py b/desdeo/api/models/__init__.py index 97701303..72906d0c 100644 --- a/desdeo/api/models/__init__.py +++ b/desdeo/api/models/__init__.py @@ -26,6 +26,13 @@ "ENautilusStepResponse", "ENautilusTreeNodeResponse", "ExtraFunctionDB", + "NautilusNavigatorInitializationState", + "NautilusNavigatorInitRequest", + "NautilusNavigatorInitResponse", + "NautilusNavigatorNavigateRequest", + "NautilusNavigatorNavigateResponse", + "NautilusNavigatorNavigationState", + "NautilusNavigatorStep", "ForestProblemMetaData", "GenericIntermediateSolutionResponse", "GNIMBUSOptimizationState", @@ -216,6 +223,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 c84164f8..aec8c242 100644 --- a/desdeo/api/models/generic_states.py +++ b/desdeo/api/models/generic_states.py @@ -18,6 +18,7 @@ from desdeo.problem import Tensor, VariableType +from .nautilus_navigator import NautilusNavigatorInitializationState, NautilusNavigatorNavigationState from .state import ( EMOFetchState, EMOIterateState, @@ -65,6 +66,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" XNIMBUS_SOLVE = "xnimbus.solve_candidates" XNIMBUS_SAVE = "xnimbus.save_solutions" XNIMBUS_INIT = "xnimbus.initialize" @@ -199,6 +202,8 @@ def state(self) -> SQLModel | None: StateKind.GENERIC_INTERMEDIATE: IntermediateSolutionState, StateKind.ENAUTILUS_STEP: ENautilusState, StateKind.ENAUTILUS_FINAL: ENautilusFinalState, + StateKind.NAUTILUS_NAVIGATE: NautilusNavigatorNavigationState, + StateKind.NAUTILUS_INITIALIZE: NautilusNavigatorInitializationState, StateKind.XNIMBUS_SOLVE: NIMBUSClassificationState, StateKind.XNIMBUS_SAVE: NIMBUSSaveState, StateKind.XNIMBUS_INIT: NIMBUSInitializationState, @@ -221,6 +226,8 @@ def state(self) -> SQLModel | None: IntermediateSolutionState: StateKind.GENERIC_INTERMEDIATE, ENautilusState: StateKind.ENAUTILUS_STEP, ENautilusFinalState: StateKind.ENAUTILUS_FINAL, + NautilusNavigatorNavigationState: StateKind.NAUTILUS_NAVIGATE, + NautilusNavigatorInitializationState: StateKind.NAUTILUS_INITIALIZE, } diff --git a/desdeo/api/models/nautilus_navigator.py b/desdeo/api/models/nautilus_navigator.py new file mode 100644 index 00000000..4fb3f739 --- /dev/null +++ b/desdeo/api/models/nautilus_navigator.py @@ -0,0 +1,142 @@ +"""Models specific to the NAUTILUS Navigator method.""" + +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +class NautilusNavigatorInitializationState(SQLModel, table=True): + """State representing initialization of a NAUTILUS Navigator session. + + This state corresponds to the execution of the `navigator_init` function + in the NAUTILUS Navigator algorithm. + + 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. + + 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" + + 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. + + This state corresponds to a call to the `navigator_all_steps` function in + the NAUTILUS Navigator algorithm. + + The design follows the standard pattern used in DESDEO method states: + + - Fields correspond to the input arguments of the algorithm function + - A single field stores the result returned by the function + + 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. + + Notes: + The parameters `problem` and `solver` are not stored in the state, + as they are provided by the surrounding application context. + + Stored Inputs (arguments to `navigator_all_steps`): + steps_remaining: + Number of navigation steps to perform. + + reference_point: + The reference point provided by the decision maker. + + previous_responses: + The list of previous NAUTILUS responses representing the + navigation history up to this point. + + bounds: + Optional bounds specified by the decision maker. + + 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" + + id: int | None = Field( + default=None, + primary_key=True, + foreign_key="states.id", + ) + + 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/generic.py b/desdeo/api/routers/generic.py index 7eb9a831..68d0934a 100644 --- a/desdeo/api/routers/generic.py +++ b/desdeo/api/routers/generic.py @@ -192,3 +192,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/routers/nautilus_navigator.py b/desdeo/api/routers/nautilus_navigator.py new file mode 100644 index 00000000..467df751 --- /dev/null +++ b/desdeo/api/routers/nautilus_navigator.py @@ -0,0 +1,159 @@ +"""Defines end-points to access functionalities related to the NAUTILUS Navigator method.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status + +from desdeo.api.models import ( + NautilusNavigatorInitRequest, + NautilusNavigatorInitResponse, + NautilusNavigatorNavigateRequest, + NautilusNavigatorNavigateResponse, + NautilusNavigatorStep, + StateDB, +) +from desdeo.api.models.nautilus_navigator import ( + NautilusNavigatorInitializationState, + NautilusNavigatorNavigationState, +) +from desdeo.mcdm.nautilus_navigator import ( + NAUTILUS_Response, + navigator_all_steps, + navigator_init, +) +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) -> NautilusNavigatorStep: + """Map a NAUTILUS_Response to a NautilusNavigatorStep.""" + reachable_bounds = response.reachable_bounds or {} + + return NautilusNavigatorStep( + 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") +def initialize_navigator( + request: NautilusNavigatorInitRequest, + context: Annotated[SessionContext, Depends(SessionContextGuard(require=[ContextField.PROBLEM]).post)], +) -> NautilusNavigatorInitResponse: + """Initialize NAUTILUS Navigator.""" + 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 + + response = navigator_init(problem) + + substate = NautilusNavigatorInitializationState() + + 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, + ) + + db_session.add(state_db) + db_session.commit() + db_session.refresh(state_db) + + reachable_bounds = response.reachable_bounds or {} + + 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", {}), + step_number=response.step_number, + distance_to_front=response.distance_to_front, + ) + + +@router.post("/navigate") +def navigate_navigator( + request: NautilusNavigatorNavigateRequest, + context: Annotated[SessionContext, Depends(SessionContextGuard(require=[ContextField.PROBLEM]).post)], +) -> NautilusNavigatorNavigateResponse: + """Perform NAUTILUS navigation steps.""" + 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_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 = [ + 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, + 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=status.HTTP_400_BAD_REQUEST, detail="Bounds are too restrictive.") from e + + substate = 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], + ) + + state_db = StateDB.create( + database_session=db_session, + problem_id=problem_db.id, + state=substate, + 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_session.add(state_db) + db_session.commit() + db_session.refresh(state_db) + + steps = [_map_response_to_step(r) for r in new_responses] + + return NautilusNavigatorNavigateResponse( + state_id=state_db.id, + steps=steps, + ) diff --git a/desdeo/api/routers/utils.py b/desdeo/api/routers/utils.py index 868e44c8..f8773e5a 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, @@ -24,7 +26,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_problem_with_role_check(user: User, problem_id: int, session: Session) -> ProblemDB | None: diff --git a/desdeo/api/tests/test_nautilus_navigator.py b/desdeo/api/tests/test_nautilus_navigator.py new file mode 100644 index 00000000..e55a86b2 --- /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 a60168ff..b7745108 100644 --- a/desdeo/api/tests/test_routes.py +++ b/desdeo/api/tests/test_routes.py @@ -60,6 +60,93 @@ from .test_models import compare_models +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"] + + # 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) + + 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 + + +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"] + + # Create a test problem using ProblemDB (the SQLModel table used by SessionContextGuard) + problem_obj = dtlz2(3, 2) # 3 variables, 2 objectives + problem_db = ProblemDB.from_problem(problem_obj, user=user) + session.add(problem_db) + session.commit() + session.refresh(problem_db) + + 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 + + objective_symbols = [obj.symbol for obj in problem_obj.objectives] + + 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, + } + + 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 + + def test_user_login(client: TestClient): """Test that login works.""" response = client.post( @@ -118,6 +205,26 @@ 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.""" access_token = login(client) diff --git a/webui/src/lib/gen/endpoints/DESDEOFastAPI.ts b/webui/src/lib/gen/endpoints/DESDEOFastAPI.ts index 9b42465c..1f2f32c4 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 9a2e1eed..352c8190 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 */