Spaces:
Sleeping
Sleeping
| """BrainRL region selection environment implementation.""" | |
| from __future__ import annotations | |
| import json | |
| import math | |
| import os | |
| import random | |
| from typing import Any | |
| from uuid import uuid4 | |
| try: | |
| from openenv.core.env_server.mcp_environment import MCPEnvironment | |
| from openenv.core.env_server.types import Action, Observation, State | |
| except ImportError: # pragma: no cover - keeps local smoke tests lightweight | |
| class MCPEnvironment: # type: ignore[no-redef] | |
| def __init__(self, mcp: Any | None = None): | |
| self.mcp = mcp | |
| def step(self, action: Any, timeout_s: float | None = None, **kwargs: Any) -> Any: | |
| return self._step_impl(action, timeout_s=timeout_s, **kwargs) | |
| async def step_async(self, action: Any, timeout_s: float | None = None, **kwargs: Any) -> Any: | |
| return self._step_impl(action, timeout_s=timeout_s, **kwargs) | |
| class Action: # type: ignore[no-redef] | |
| pass | |
| class Observation: # type: ignore[no-redef] | |
| def __init__(self, done: bool = False, reward: float = 0.0, metadata: dict | None = None): | |
| self.done = done | |
| self.reward = reward | |
| self.metadata = metadata or {} | |
| class State: # type: ignore[no-redef] | |
| def __init__(self, episode_id: str, step_count: int = 0): | |
| self.episode_id = episode_id | |
| self.step_count = step_count | |
| try: | |
| from fastmcp import FastMCP | |
| except ImportError: # pragma: no cover | |
| class FastMCP: # type: ignore[no-redef] | |
| def __init__(self, name: str): | |
| self.name = name | |
| def tool(self, func): | |
| return func | |
| from data_loader import ( | |
| BrainSubset, | |
| DEFAULT_CONFIG_PATH, | |
| RegionCandidate, | |
| _parse_simple_yaml, | |
| candidate_table, | |
| load_brain_subset, | |
| ) | |
| from rewards import format_reward_breakdown, reward_columns, score_region_action | |
| from stimulus_loader import ( | |
| compact_for_prompt as compact_stimulus_for_prompt, | |
| n_windows as stimulus_n_windows, | |
| stimulus_bias_for_group, | |
| summarize_window as summarize_stimulus_window, | |
| ) | |
| def _env_int(name: str, default: int) -> int: | |
| raw = os.environ.get(name) | |
| if not raw: | |
| return default | |
| try: | |
| return int(raw) | |
| except (TypeError, ValueError): | |
| return default | |
| def _yaml_stimulus_defaults() -> tuple[int, int]: | |
| """Read stimulus_window_size / stimulus_top_words from subset_config.yaml.""" | |
| try: | |
| config = _parse_simple_yaml(DEFAULT_CONFIG_PATH) | |
| except Exception: # pragma: no cover - defensive: never fail env construction | |
| config = {} | |
| window = int(config.get("stimulus_window_size", 30) or 30) | |
| top = int(config.get("stimulus_top_words", 10) or 10) | |
| return max(1, window), max(1, top) | |
| _yaml_window, _yaml_top = _yaml_stimulus_defaults() | |
| _DEFAULT_STIMULUS_WINDOW_SIZE = _env_int("BRAINRL_STIMULUS_WINDOW_SIZE", _yaml_window) | |
| _DEFAULT_STIMULUS_TOP_WORDS = _env_int("BRAINRL_STIMULUS_TOP_WORDS", _yaml_top) | |
| TASKS = { | |
| "roi_selection": { | |
| "description": ( | |
| "Sequentially select brain regions that improve prediction of auditory " | |
| "stimulus responses from a compact Le Petit Prince fMRI summary." | |
| ), | |
| "difficulty": "medium", | |
| "reward": "independent_verifier_components", | |
| "valid_actions": "Any unselected candidate region_id", | |
| } | |
| } | |
| class BrainRegionSelectionEnvironment(MCPEnvironment): | |
| """OpenEnv-style environment for active brain region acquisition.""" | |
| def __init__(self): | |
| mcp = FastMCP("brain_rl_region_selection") | |
| def get_selection_state() -> dict: | |
| """Return selected regions, current score, budget, and candidates.""" | |
| return self._build_selection_state() | |
| def get_task_info() -> dict: | |
| """Return task metadata and reward definition.""" | |
| return self._build_task_info() | |
| def take_action(region_id: str) -> dict: | |
| """Select the next region and advance one acquisition step.""" | |
| return self._process_action(region_id) | |
| super().__init__(mcp) | |
| self._state = State(episode_id=str(uuid4()), step_count=0) | |
| self._rng = random.Random(42) | |
| self._subset: BrainSubset = load_brain_subset() | |
| self._task = "roi_selection" | |
| self._episode_id = str(uuid4()) | |
| self._timestep = 0 | |
| self._selected_region_ids: list[str] = [] | |
| self._current_r2 = 0.0 | |
| self._last_feedback = "Environment initialized" | |
| self._subject_id: str | None = None | |
| self._run_id: str | None = None | |
| self._condition: str | None = None | |
| self._stimulus_window_size: int = _DEFAULT_STIMULUS_WINDOW_SIZE | |
| self._stimulus_top_words: int = _DEFAULT_STIMULUS_TOP_WORDS | |
| self._stimulus_window_index: int | None = None | |
| self._stimulus_features: dict | None = None | |
| self._effective_base_r2: dict[str, float] = self._compute_effective_base_r2() | |
| def reset( | |
| self, | |
| seed: int | None = None, | |
| episode_id: str | None = None, | |
| task: str = "roi_selection", | |
| subject_id: str | None = None, | |
| run_id: str | None = None, | |
| condition: str | None = None, | |
| stimulus_window: int | None = None, | |
| stimulus_window_size: int | None = None, | |
| stimulus_top_words: int | None = None, | |
| **_: Any, | |
| ) -> Observation: | |
| if task not in TASKS: | |
| raise ValueError(f"Unknown task={task}. Valid tasks: {sorted(TASKS)}") | |
| if seed is not None: | |
| self._rng = random.Random(seed) | |
| self._subset = load_brain_subset() | |
| self._task = task | |
| self._episode_id = episode_id or str(uuid4()) | |
| self._state = State(episode_id=self._episode_id, step_count=0) | |
| self._timestep = 0 | |
| self._selected_region_ids = [] | |
| self._current_r2 = 0.0 | |
| self._subject_id = subject_id | |
| self._run_id = run_id | |
| self._condition = condition | |
| if stimulus_window_size is not None: | |
| self._stimulus_window_size = max(1, int(stimulus_window_size)) | |
| if stimulus_top_words is not None: | |
| self._stimulus_top_words = max(1, int(stimulus_top_words)) | |
| self._stimulus_window_index = ( | |
| int(stimulus_window) if stimulus_window is not None else None | |
| ) | |
| self._stimulus_features = self._build_stimulus_features(seed=seed) | |
| # ``_effective_base_r2`` depends on the active stimulus window so it | |
| # has to be recomputed every reset(), not just on subject/condition | |
| # changes. | |
| self._effective_base_r2 = self._compute_effective_base_r2() | |
| ctx = self._context_label() | |
| self._last_feedback = f"Select the first brain region. ({ctx})" if ctx else "Select the first brain region." | |
| return Observation(done=False, reward=0.0, metadata=self._build_observation()) | |
| def _build_stimulus_features(self, *, seed: int | None) -> dict | None: | |
| """Resolve the stimulus window for this episode (if data is available).""" | |
| if not self._condition: | |
| return None | |
| # Deterministic key: episode varies stimulus across resets even when | |
| # subject/run repeat, while staying reproducible for a given seed. | |
| deterministic_key = ( | |
| self._subject_id or "_", | |
| self._run_id or "_", | |
| int(seed) if seed is not None else 0, | |
| self._episode_id, | |
| ) | |
| return summarize_stimulus_window( | |
| self._condition, | |
| window_index=self._stimulus_window_index, | |
| window_size=self._stimulus_window_size, | |
| top_words=self._stimulus_top_words, | |
| deterministic_key=deterministic_key, | |
| ) | |
| def _step_impl( | |
| self, | |
| action: Action, | |
| timeout_s: float | None = None, | |
| **kwargs: Any, | |
| ) -> Observation: | |
| region_id = getattr(action, "region_id", None) | |
| if region_id is None and isinstance(action, dict): | |
| region_id = action.get("region_id") | |
| result = self._process_action(str(region_id)) | |
| return Observation( | |
| done=bool(result["done"]), | |
| reward=float(result["reward"]), | |
| metadata=self._build_observation(extra=result), | |
| ) | |
| def step(self, action: Action, timeout_s: float | None = None, **kwargs: Any) -> Observation: | |
| self._state.step_count += 1 | |
| return super().step(action, timeout_s=timeout_s, **kwargs) | |
| async def step_async( | |
| self, | |
| action: Action, | |
| timeout_s: float | None = None, | |
| **kwargs: Any, | |
| ) -> Observation: | |
| self._state.step_count += 1 | |
| return await super().step_async(action, timeout_s=timeout_s, **kwargs) | |
| def state(self) -> State: | |
| return self._state | |
| def _candidate_by_id(self) -> dict[str, RegionCandidate]: | |
| return {candidate.region_id: candidate for candidate in self._subset.candidates} | |
| def _selected_candidates(self) -> list[RegionCandidate]: | |
| by_id = self._candidate_by_id() | |
| return [by_id[region_id] for region_id in self._selected_region_ids if region_id in by_id] | |
| def _context_label(self) -> str: | |
| parts = [ | |
| f"subject={self._subject_id}" if self._subject_id else "", | |
| f"run={self._run_id}" if self._run_id else "", | |
| f"condition={self._condition}" if self._condition else "", | |
| ] | |
| return ", ".join(p for p in parts if p) | |
| def _condition_boost(self, candidate: RegionCandidate) -> float: | |
| """Per-condition multiplier so train/test conditions reward differently. | |
| ``single_m`` (single male narrator) emphasizes auditory/language ROIs; | |
| ``single_f`` does the same with a slight twist; ``mixed_*`` rewards a | |
| broader set of association regions. | |
| """ | |
| condition = (self._condition or "").lower() | |
| group = candidate.redundancy_group | |
| if condition == "single_m": | |
| return 1.20 if group == "auditory_temporal" else (1.05 if group == "inferior_frontal" else 0.95) | |
| if condition == "single_f": | |
| return 1.18 if group == "auditory_temporal" else (1.04 if group == "inferior_frontal" else 0.96) | |
| if condition == "mixed_m": | |
| return 1.10 if group in {"auditory_temporal", "inferior_frontal"} else 1.02 | |
| if condition == "mixed_f": | |
| return 1.08 if group in {"auditory_temporal", "association"} else 1.0 | |
| return 1.0 | |
| def _subject_perturbation(self, candidate: RegionCandidate) -> float: | |
| """Deterministic per-subject jitter in [0.7, 1.3]. | |
| Hash-based so the same (subject, parcel) always gives the same value | |
| but different subjects experience different reward landscapes - which | |
| is what makes train/test generalization meaningful. | |
| """ | |
| if not self._subject_id: | |
| return 1.0 | |
| h = abs(hash((self._subject_id, candidate.region_id))) % 10_000 | |
| return 0.7 + (h / 10_000.0) * 0.6 | |
| def _stimulus_bias(self, candidate: RegionCandidate) -> float: | |
| """Per-window stimulus multiplier for this candidate's group. | |
| Bounded to a small range so it nudges the policy toward parcels | |
| that match the current stimulus content (e.g. nouns/density → | |
| auditory_temporal, function/syntactic words → inferior_frontal) | |
| without overwhelming the underlying base_r2 ranking. | |
| """ | |
| return stimulus_bias_for_group(candidate.redundancy_group, self._stimulus_features) | |
| def _compute_effective_base_r2(self) -> dict[str, float]: | |
| effective: dict[str, float] = {} | |
| for candidate in self._subset.candidates: | |
| value = ( | |
| candidate.base_r2 | |
| * self._condition_boost(candidate) | |
| * self._subject_perturbation(candidate) | |
| * self._stimulus_bias(candidate) | |
| ) | |
| effective[candidate.region_id] = float(max(0.001, min(0.30, value))) | |
| return effective | |
| def _score_regions(self, region_ids: list[str]) -> float: | |
| by_id = self._candidate_by_id() | |
| selected = [by_id[region_id] for region_id in region_ids if region_id in by_id] | |
| if not selected: | |
| return 0.0 | |
| total = 0.0 | |
| group_counts: dict[str, int] = {} | |
| for candidate in selected: | |
| group_count = group_counts.get(candidate.redundancy_group, 0) | |
| diminishing_return = 0.72 ** group_count | |
| base = self._effective_base_r2.get(candidate.region_id, candidate.base_r2) | |
| total += base * diminishing_return | |
| group_counts[candidate.redundancy_group] = group_count + 1 | |
| # Bound cumulative explained variance to keep rewards stable. | |
| return float(1.0 - math.exp(-total)) | |
| def _process_action(self, region_id: str) -> dict: | |
| by_id = self._candidate_by_id() | |
| previous_r2 = self._current_r2 | |
| if self._timestep >= self._subset.selection_budget: | |
| self._last_feedback = "Budget exhausted; episode already complete." | |
| return self._result( | |
| done=True, | |
| error="budget_exhausted", | |
| previous_r2=previous_r2, | |
| cost_penalty=0.0, | |
| ) | |
| if region_id not in by_id: | |
| self._last_feedback = f"Invalid region_id={region_id}." | |
| return self._result( | |
| done=False, | |
| error="invalid_region", | |
| previous_r2=previous_r2, | |
| cost_penalty=0.0, | |
| ) | |
| if region_id in self._selected_region_ids: | |
| self._last_feedback = f"Region {region_id} was already selected." | |
| self._timestep += 1 | |
| return self._result( | |
| done=self._is_done(), | |
| error="duplicate_region", | |
| previous_r2=previous_r2, | |
| cost_penalty=0.0, | |
| ) | |
| candidate = by_id[region_id] | |
| self._selected_region_ids.append(region_id) | |
| self._timestep += 1 | |
| self._current_r2 = self._score_regions(self._selected_region_ids) | |
| delta_r2 = self._current_r2 - previous_r2 | |
| cost_penalty = self._subset.cost_penalty * candidate.cost | |
| done = self._is_done() | |
| reward_breakdown = score_region_action( | |
| previous_r2=previous_r2, | |
| current_r2=self._current_r2, | |
| cost_penalty=cost_penalty, | |
| error=None, | |
| done=done, | |
| selected_count=len(self._selected_region_ids), | |
| ) | |
| self._last_feedback = ( | |
| f"Selected {region_id}: delta_r2={delta_r2:.4f}, " | |
| f"reward_components=[{format_reward_breakdown(reward_breakdown.as_dict())}]." | |
| ) | |
| return self._result( | |
| done=done, | |
| error=None, | |
| previous_r2=previous_r2, | |
| cost_penalty=cost_penalty, | |
| reward_components=reward_breakdown.as_dict(), | |
| ) | |
| def _is_done(self) -> bool: | |
| return self._timestep >= self._subset.selection_budget or ( | |
| len(self._selected_region_ids) >= self._subset.n_regions | |
| ) | |
| def _result( | |
| self, | |
| done: bool, | |
| error: str | None, | |
| previous_r2: float, | |
| cost_penalty: float, | |
| reward_components: dict[str, float] | None = None, | |
| ) -> dict: | |
| if reward_components is None: | |
| reward_components = score_region_action( | |
| previous_r2=previous_r2, | |
| current_r2=self._current_r2, | |
| cost_penalty=cost_penalty, | |
| error=error, | |
| done=done, | |
| selected_count=len(self._selected_region_ids), | |
| ).as_dict() | |
| return { | |
| "episode_id": self._episode_id, | |
| "reward": float(reward_components["total_reward"]), | |
| "reward_components": reward_components, | |
| "done": bool(done), | |
| "error": error, | |
| "previous_r2": float(previous_r2), | |
| "current_r2": float(self._current_r2), | |
| "score": float(self._current_r2), | |
| "selection_state": self._build_selection_state(), | |
| "feedback": self._last_feedback, | |
| } | |
| def _build_task_info(self) -> dict: | |
| task = TASKS[self._task] | |
| return { | |
| "task_name": self._task, | |
| "description": task["description"], | |
| "difficulty": task["difficulty"], | |
| "reward": task["reward"], | |
| "reward_components": reward_columns(), | |
| "valid_actions": task["valid_actions"], | |
| "dataset_name": self._subset.dataset_name, | |
| "data_source": self._subset.source, | |
| "candidate_mode": self._subset.candidate_mode, | |
| "atlas": self._subset.atlas, | |
| "selection_budget": int(self._subset.selection_budget), | |
| "prompt_top_k": int(self._subset.prompt_top_k), | |
| "cost_penalty": float(self._subset.cost_penalty), | |
| "n_candidate_regions": int(self._subset.n_regions), | |
| "subject_id": self._subject_id, | |
| "run_id": self._run_id, | |
| "condition": self._condition, | |
| "stimulus": compact_stimulus_for_prompt(self._stimulus_features), | |
| "stimulus_window_size": int(self._stimulus_window_size), | |
| "stimulus_n_windows": int( | |
| stimulus_n_windows(self._condition or "", window_size=self._stimulus_window_size) | |
| if self._condition | |
| else 0 | |
| ), | |
| } | |
| def _build_selection_state(self) -> dict: | |
| selected_set = set(self._selected_region_ids) | |
| candidates = [] | |
| for candidate in self._subset.candidates: | |
| item = candidate.as_dict() | |
| item["selected"] = candidate.region_id in selected_set | |
| candidates.append(item) | |
| payload: dict[str, Any] = { | |
| "episode_id": self._episode_id, | |
| "task_name": self._task, | |
| "timestep": int(self._timestep), | |
| "selection_budget": int(self._subset.selection_budget), | |
| "remaining_budget": int(max(0, self._subset.selection_budget - self._timestep)), | |
| "selected_regions": list(self._selected_region_ids), | |
| "current_r2": float(self._current_r2), | |
| "candidate_regions": candidates, | |
| "candidate_count": int(self._subset.n_regions), | |
| "candidate_mode": self._subset.candidate_mode, | |
| "atlas": self._subset.atlas, | |
| "prompt_top_k": int(self._subset.prompt_top_k), | |
| "dataset_name": self._subset.dataset_name, | |
| "data_source": self._subset.source, | |
| "subject_id": self._subject_id, | |
| "run_id": self._run_id, | |
| "condition": self._condition, | |
| "feedback": self._last_feedback, | |
| } | |
| if self._stimulus_features: | |
| payload["stimulus"] = compact_stimulus_for_prompt(self._stimulus_features) | |
| payload["stimulus_window"] = int(self._stimulus_features.get("window_index", 0)) | |
| payload["stimulus_n_windows"] = int(self._stimulus_features.get("n_windows", 1)) | |
| else: | |
| payload["stimulus"] = None | |
| return payload | |
| def _build_observation(self, extra: dict | None = None) -> dict: | |
| payload = { | |
| "selection_state": json.dumps(self._build_selection_state()), | |
| "task_name": self._task, | |
| "timestep": int(self._timestep), | |
| "max_timesteps": int(self._subset.selection_budget), | |
| "feedback": self._last_feedback, | |
| "score": float(self._current_r2), | |
| } | |
| if extra: | |
| payload.update(extra) | |
| return payload | |
| def render_text(self) -> str: | |
| selected = ", ".join(self._selected_region_ids) or "none" | |
| return ( | |
| f"BrainRL step={self._timestep}/{self._subset.selection_budget} " | |
| f"r2={self._current_r2:.4f} selected=[{selected}]" | |
| ) | |
| def load_default_candidates() -> list[dict[str, Any]]: | |
| """Convenience helper for scripts that only need candidate metadata.""" | |
| return candidate_table(load_brain_subset()) | |