mol_opt-env / env.py
Jin2413's picture
Add task-selectable custom web UI tab-2
9f02ed5
Raw
History Blame Contribute Delete
10.2 kB
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")
@mcp.tool
def get_properties() -> str:
return self._current_properties_json()
@mcp.tool
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)
@mcp.tool
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)
@property
def state(self) -> State:
return self._state