Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import json | |
| import importlib | |
| from typing import Any, Optional | |
| from uuid import uuid4 | |
| import rdkit.Chem as Chem | |
| import rdkit.Chem.QED as QED | |
| from fastmcp import FastMCP | |
| from rdkit.Chem import Descriptors, Lipinski | |
| from models import EpisodeState, MolOptObservation, MoleculeAction, MoleculeProperties, RewardModel | |
| from rubrics import DEFAULT_TASK, TASKS, compute_reward, grade_episode | |
| try: | |
| from openenv.core.env_server.mcp_environment import MCPEnvironment | |
| from openenv.core.env_server.types import Action, Observation, State | |
| except ImportError: | |
| from openenv.core.env_server.mcp_environment import MCPEnvironment | |
| from openenv.core.env_server.types import Action, Observation, State | |
| try: | |
| _sascorer = importlib.import_module("server.sascorer") | |
| _sascorer.readFragmentScores() | |
| _HAS_SA_SCORER = True | |
| except Exception: | |
| _sascorer = None | |
| _HAS_SA_SCORER = False | |
| def sa_score_from_mol(mol: Chem.Mol) -> float: | |
| if _HAS_SA_SCORER and _sascorer is not None: | |
| try: | |
| return float(_sascorer.calculateScore(mol)) | |
| except Exception: | |
| pass | |
| bertz = Descriptors.BertzCT(mol) | |
| return float(max(1.0, min(10.0, 1.0 + bertz / 250.0))) | |
| def compute_properties(smiles: str) -> Optional[MoleculeProperties]: | |
| mol = Chem.MolFromSmiles(smiles) | |
| if mol is None: | |
| return None | |
| canonical = Chem.MolToSmiles(mol) | |
| mw = round(Descriptors.MolWt(mol), 2) | |
| logp = round(Descriptors.MolLogP(mol), 4) | |
| hbd = int(Lipinski.NumHDonors(mol)) | |
| hba = int(Lipinski.NumHAcceptors(mol)) | |
| violations = int(mw > 500) + int(logp > 5) + int(hbd > 5) + int(hba > 10) | |
| return MoleculeProperties( | |
| smiles=canonical, | |
| qed=round(QED.default(mol), 4), | |
| logp=logp, | |
| molecular_weight=mw, | |
| hbd=hbd, | |
| hba=hba, | |
| tpsa=round(Descriptors.TPSA(mol), 2), | |
| rotatable_bonds=int(Lipinski.NumRotatableBonds(mol)), | |
| sa_score=round(sa_score_from_mol(mol), 3), | |
| lipinski_violations=violations, | |
| ) | |
| class MolOptEnvironment(MCPEnvironment): | |
| def __init__(self) -> None: | |
| mcp = FastMCP("molopt_env") | |
| def get_properties() -> str: | |
| return self._current_properties_json() | |
| def get_task_info() -> str: | |
| task = TASKS[self._task_name] | |
| payload = { | |
| "task_name": task.name, | |
| "difficulty": task.difficulty, | |
| "description": task.description, | |
| "start_smiles": task.start_smiles, | |
| "max_steps": task.max_steps, | |
| "step": self._step_count, | |
| "steps_remaining": max(task.max_steps - self._step_count, 0), | |
| } | |
| return json.dumps(payload, indent=2) | |
| def modify_molecule(new_smiles: str) -> str: | |
| parsed = MoleculeAction(new_smiles=new_smiles) | |
| return json.dumps(self._apply_modification(parsed.new_smiles), indent=2) | |
| super().__init__(mcp) | |
| self._task_name = DEFAULT_TASK | |
| self._step_count = 0 | |
| self._done = False | |
| self._last_action_error: Optional[str] = None | |
| self._last_reward = RewardModel(value=0.0, objective_score=0.0, progress_delta=0.0, penalty=0.0, reason="reset") | |
| self._episode = EpisodeState( | |
| task_name=DEFAULT_TASK, | |
| current_smiles=TASKS[DEFAULT_TASK].start_smiles, | |
| step_count=0, | |
| max_steps=TASKS[DEFAULT_TASK].max_steps, | |
| visited_smiles=[], | |
| ) | |
| self._state = State(episode_id=str(uuid4()), step_count=0) | |
| self._current_props = compute_properties(TASKS[DEFAULT_TASK].start_smiles) | |
| self._previous_props = self._current_props | |
| def _current_properties_json(self) -> str: | |
| props = self._current_props or compute_properties(self._episode.current_smiles) | |
| payload = (props.model_dump() if props is not None else {"error": "invalid_molecule"}) | |
| payload["step"] = self._step_count | |
| payload["steps_remaining"] = max(self._episode.max_steps - self._step_count, 0) | |
| return json.dumps(payload, indent=2) | |
| def _build_observation_model(self, final_score: Optional[float] = None) -> MolOptObservation: | |
| task = TASKS[self._task_name] | |
| props = self._current_props | |
| if props is None: | |
| raise RuntimeError("Current properties are unavailable.") | |
| return MolOptObservation( | |
| task_name=self._task_name, | |
| difficulty=task.difficulty, | |
| step=self._step_count, | |
| steps_remaining=max(task.max_steps - self._step_count, 0), | |
| done=self._done, | |
| properties=props, | |
| reward=self._last_reward, | |
| message=task.description, | |
| last_action_error=self._last_action_error, | |
| final_score=final_score, | |
| ) | |
| def _apply_modification(self, new_smiles: str) -> dict[str, Any]: | |
| current_props = self._current_props | |
| proposed_props = compute_properties(new_smiles) | |
| self._last_action_error = None | |
| if proposed_props is None: | |
| self._last_action_error = f"Invalid SMILES: {new_smiles}" | |
| fallback_props = current_props | |
| if fallback_props is None: | |
| raise RuntimeError("Environment has no current molecule state.") | |
| self._last_reward = compute_reward(self._task_name, fallback_props, self._previous_props, invalid=True) | |
| return {"success": False, "error": self._last_action_error, "reward": self._last_reward.value} | |
| if current_props is not None and proposed_props.smiles == current_props.smiles: | |
| self._last_action_error = "No change: submitted the same molecule" | |
| self._last_reward = compute_reward(self._task_name, current_props, self._previous_props, unchanged=True) | |
| return {"success": False, "error": self._last_action_error, "reward": self._last_reward.value} | |
| if proposed_props.smiles in self._episode.visited_smiles: | |
| self._last_action_error = "Repetition: molecule already visited" | |
| self._last_reward = compute_reward(self._task_name, proposed_props, self._previous_props, repeated=True) | |
| return {"success": False, "error": self._last_action_error, "reward": self._last_reward.value} | |
| self._last_reward = compute_reward(self._task_name, proposed_props, self._previous_props) | |
| self._previous_props = current_props | |
| self._current_props = proposed_props | |
| self._episode.current_smiles = proposed_props.smiles | |
| self._episode.visited_smiles.append(proposed_props.smiles) | |
| return { | |
| "success": True, | |
| "reward": self._last_reward.value, | |
| "objective_score": self._last_reward.objective_score, | |
| "properties": proposed_props.model_dump(), | |
| } | |
| def reset( | |
| self, | |
| seed: Optional[int] = None, | |
| episode_id: Optional[str] = None, | |
| task: str = DEFAULT_TASK, | |
| **kwargs: Any, | |
| ) -> Observation: | |
| if task not in TASKS: | |
| task = DEFAULT_TASK | |
| spec = TASKS[task] | |
| start_props = compute_properties(spec.start_smiles) | |
| if start_props is None: | |
| raise ValueError(f"Invalid starting SMILES for task {task}: {spec.start_smiles}") | |
| self._task_name = task | |
| self._step_count = 0 | |
| self._done = False | |
| self._last_action_error = None | |
| self._current_props = start_props | |
| self._previous_props = start_props | |
| self._last_reward = RewardModel( | |
| value=0.0, | |
| objective_score=grade_episode(task, start_props), | |
| progress_delta=0.0, | |
| penalty=0.0, | |
| reason="reset", | |
| ) | |
| self._episode = EpisodeState( | |
| task_name=task, | |
| current_smiles=start_props.smiles, | |
| step_count=0, | |
| max_steps=spec.max_steps, | |
| done=False, | |
| visited_smiles=[start_props.smiles], | |
| ) | |
| self._state = State(episode_id=episode_id or str(uuid4()), step_count=0) | |
| obs_model = self._build_observation_model() | |
| return Observation(done=False, reward=0.0, metadata=obs_model.model_dump()) | |
| def _step_impl(self, action: Action, timeout_s: Optional[float] = None, **kwargs: Any) -> Observation: | |
| return Observation( | |
| done=self._done, | |
| reward=self._last_reward.value, | |
| metadata={"error": f"Unsupported action type {type(action).__name__}. Use MCP tool actions."}, | |
| ) | |
| def _finalize_observation(self, obs: Observation) -> Observation: | |
| self._done = self._step_count >= self._episode.max_steps | |
| self._episode.step_count = self._step_count | |
| self._episode.done = self._done | |
| final_score = grade_episode(self._task_name, self._current_props) if (self._done and self._current_props is not None) else None | |
| obs.reward = self._last_reward.value | |
| obs.done = self._done | |
| if obs.metadata is None: | |
| obs.metadata = {} | |
| obs.metadata.update(self._build_observation_model(final_score=final_score).model_dump()) | |
| return obs | |
| def _should_count_step(self, action: Action) -> bool: | |
| tool_name = getattr(action, "tool_name", None) | |
| if isinstance(tool_name, str): | |
| return tool_name == "modify_molecule" | |
| return True | |
| def step(self, action: Action, timeout_s: Optional[float] = None, **kwargs: Any) -> Observation: | |
| if self._should_count_step(action): | |
| self._step_count += 1 | |
| self._state.step_count = self._step_count | |
| obs = super().step(action, timeout_s=timeout_s, **kwargs) | |
| return self._finalize_observation(obs) | |
| async def step_async(self, action: Action, timeout_s: Optional[float] = None, **kwargs: Any) -> Observation: | |
| if self._should_count_step(action): | |
| self._step_count += 1 | |
| self._state.step_count = self._step_count | |
| obs = await super().step_async(action, timeout_s=timeout_s, **kwargs) | |
| return self._finalize_observation(obs) | |
| def state(self) -> State: | |
| return self._state | |