chess_env / src /core /rubrics /containers.py
sergiopaniego's picture
sergiopaniego HF Staff
Upload folder using huggingface_hub
7aca0bc verified
Raw History Blame Contribute Delete
16.8 kB
# SPDX-License-Identifier: BSD-3-Clause
"""Container rubrics for composing reward computations.
These containers provide common aggregation patterns for rubrics,
similar to how PyTorch provides nn.Sequential alongside nn.Module.
See RFC 004 for full design: rfcs/004-rubrics.md
"""
import asyncio
import inspect
from typing import Any, Dict, Iterator, List, Mapping, Tuple, Union
from openenv.core.rubrics.base import Rubric
def _in_async_context() -> bool:
"""Check if we're currently in an async context."""
try:
asyncio.get_running_loop()
return True
except RuntimeError:
return False
class Sequential(Rubric):
"""Run rubrics in order, fail-fast on zero.
Runs child rubrics in order. If any returns 0, stops immediately
and returns 0. This implements hierarchical gating patterns where
syntax checks run before execution checks.
Examples:
```python
rubric = Sequential(
Gate(Compiles()),
Gate(PassesTests(), threshold=0.5),
WeightedSum([PassesTests(), StyleRubric()], weights=[0.7, 0.3])
)
```
"""
def __init__(self, *rubrics: Rubric):
"""Initialize with rubrics to run in sequence.
Args:
*rubrics (`Rubric`):
Rubrics to run in order. Stops and returns 0 if any child returns 0.
"""
super().__init__()
for i, rubric in enumerate(rubrics):
setattr(self, f"rubric_{i}", rubric)
self._rubric_list = list(rubrics)
def forward(self, action: Any, observation: Any) -> float:
"""Run rubrics in order, return 0 if any returns 0. Sync version."""
result = 1.0
for rubric in self._rubric_list:
score = rubric(action, observation)
if score == 0.0:
return 0.0
result = score
return result
def __call__(self, action: Any, observation: Any):
"""Override to choose sync or async path based on children."""
# Empty case - check if in async context
if not self._rubric_list:
if _in_async_context():
return self._empty_async(action, observation)
else:
self._run_forward_pre_hooks(action, observation)
return self._finish_forward(action, observation, 1.0)
# Pre-hooks run before any child is evaluated
ran = self._run_leading_sync_pre_hooks(action, observation)
# Call first rubric to see if it's async
first_result = self._rubric_list[0](action, observation)
if inspect.iscoroutine(first_result):
# At least one child is async, use async path
return self._call_async_detected(action, observation, first_result, ran)
else:
# Continue with sync path
if first_result == 0.0:
self._run_forward_pre_hooks(action, observation, start=ran)
return self._finish_forward(action, observation, 0.0)
final_result = first_result
for i, rubric in enumerate(self._rubric_list[1:], start=1):
score = rubric(action, observation)
if inspect.iscoroutine(score):
# Found async mid-way, switch to async
# We already called rubric at index i, so pass the coroutine and remaining rubrics
return self._call_async_mid(
action,
observation,
score,
self._rubric_list[i + 1 :],
ran,
)
if score == 0.0:
self._run_forward_pre_hooks(action, observation, start=ran)
return self._finish_forward(action, observation, 0.0)
final_result = score
# All sync - check if in async context
if _in_async_context():
return self._wrap_sync_result(action, observation, final_result, ran)
else:
self._run_forward_pre_hooks(action, observation, start=ran)
return self._finish_forward(action, observation, final_result)
async def _empty_async(self, action, observation):
"""Async path for empty sequential."""
await self._run_forward_pre_hooks_async(action, observation)
return await self._finish_forward_async(action, observation, 1.0)
async def _wrap_sync_result(self, action, observation, result, ran=0):
"""Wrap sync result for async context."""
await self._run_forward_pre_hooks_async(action, observation, start=ran)
return await self._finish_forward_async(action, observation, result)
async def _call_async_detected(self, action, observation, first_coro, ran=0):
"""Async path when first child is async."""
await self._run_forward_pre_hooks_async(action, observation, start=ran)
result = await first_coro
if result == 0.0:
return await self._finish_forward_async(action, observation, result)
for rubric in self._rubric_list[1:]:
score = rubric(action, observation)
if inspect.iscoroutine(score):
score = await score
if score == 0.0:
return await self._finish_forward_async(action, observation, 0.0)
result = score
return await self._finish_forward_async(action, observation, result)
async def _call_async_mid(
self, action, observation, first_async_coro, remaining, ran=0
):
"""Async path when async detected mid-execution."""
await self._run_forward_pre_hooks_async(action, observation, start=ran)
# Await the first async rubric (already called)
result = await first_async_coro
if result == 0.0:
return await self._finish_forward_async(action, observation, 0.0)
# Continue with remaining rubrics
for rubric in remaining:
score = rubric(action, observation)
if inspect.iscoroutine(score):
score = await score
if score == 0.0:
return await self._finish_forward_async(action, observation, 0.0)
result = score
return await self._finish_forward_async(action, observation, result)
def __len__(self) -> int:
return len(self._rubric_list)
def __getitem__(self, index: int) -> Rubric:
return self._rubric_list[index]
class Gate(Rubric):
"""Threshold wrapper - returns 0 if child score is below threshold.
Useful for hard constraints like "must pass 50% of tests".
Examples:
```python
rubric = Gate(PassesTests(), threshold=0.5)
# Returns PassesTests() score if >= 0.5, else 0.0
```
"""
def __init__(self, rubric: Rubric, threshold: float = 1.0):
"""Initialize with a rubric and threshold.
Args:
rubric (`Rubric`):
The rubric to gate.
threshold (`float`, *optional*, defaults to `1.0`):
Minimum score required. If child returns less than this, Gate returns 0.
"""
super().__init__()
self.rubric = rubric
self.threshold = threshold
def forward(self, action: Any, observation: Any) -> float:
"""Return child score if >= threshold, else 0. Sync version."""
score = self.rubric(action, observation)
if score < self.threshold:
return 0.0
return score
def __call__(self, action: Any, observation: Any):
"""Override to handle async child."""
# Pre-hooks run before the child is evaluated
ran = self._run_leading_sync_pre_hooks(action, observation)
# Call child
score = self.rubric(action, observation)
if inspect.iscoroutine(score):
# Child is async
return self._call_async(action, observation, score, ran)
else:
# Child is sync
self._run_forward_pre_hooks(action, observation, start=ran)
result = 0.0 if score < self.threshold else score
return self._finish_forward(action, observation, result)
async def _call_async(self, action, observation, score_coro, ran=0):
"""Async path."""
await self._run_forward_pre_hooks_async(action, observation, start=ran)
score = await score_coro
result = 0.0 if score < self.threshold else score
return await self._finish_forward_async(action, observation, result)
class WeightedSum(Rubric):
"""Weighted combination of child rubrics.
Standard aggregation pattern for multi-criteria evaluation.
Examples:
```python
rubric = WeightedSum(
[PassesTests(), StyleRubric()],
weights=[0.7, 0.3]
)
```
"""
def __init__(self, rubrics: List[Rubric], weights: List[float]):
"""Initialize with rubrics and weights.
Args:
rubrics (`list[``Rubric``]`):
List of rubrics to combine.
weights (`list[float]`):
Weight for each rubric. Must sum to 1.0.
Raises:
ValueError: If lengths don't match or weights don't sum to 1.0.
"""
super().__init__()
if len(rubrics) != len(weights):
raise ValueError(
f"Number of rubrics ({len(rubrics)}) must match "
f"number of weights ({len(weights)})"
)
if abs(sum(weights) - 1.0) > 1e-6:
raise ValueError(f"Weights must sum to 1.0, got {sum(weights)}")
for i, rubric in enumerate(rubrics):
setattr(self, f"rubric_{i}", rubric)
self._rubric_list = list(rubrics)
self._weights = list(weights)
def forward(self, action: Any, observation: Any) -> float:
"""Return weighted sum of child scores. Sync version."""
total = 0.0
for rubric, weight in zip(self._rubric_list, self._weights):
score = rubric(action, observation)
total += score * weight
return total
def __call__(self, action: Any, observation: Any):
"""Override to handle async children with parallel execution."""
# Pre-hooks run before any child is evaluated
ran = self._run_leading_sync_pre_hooks(action, observation)
# Call all rubrics
results = [rubric(action, observation) for rubric in self._rubric_list]
# Check if any are async
has_async = any(inspect.iscoroutine(r) for r in results)
if has_async:
# Use async path
return self._call_async(action, observation, results, ran)
else:
# Sync path
self._run_forward_pre_hooks(action, observation, start=ran)
total = 0.0
for score, weight in zip(results, self._weights):
total += score * weight
return self._finish_forward(action, observation, total)
async def _call_async(self, action, observation, results, ran=0):
"""Async path with parallel execution."""
await self._run_forward_pre_hooks_async(action, observation, start=ran)
# Separate sync and async results
async_tasks = []
async_indices = []
scores = [None] * len(results)
for i, result in enumerate(results):
if inspect.iscoroutine(result):
async_tasks.append(result)
async_indices.append(i)
else:
scores[i] = result
# Await all async tasks in parallel
if async_tasks:
async_scores = await asyncio.gather(*async_tasks)
for i, score in zip(async_indices, async_scores):
scores[i] = score
# Compute weighted sum
total = 0.0
for score, weight in zip(scores, self._weights):
total += score * weight
return await self._finish_forward_async(action, observation, total)
@property
def weights(self) -> List[float]:
"""Get the weights (read-only copy)."""
return list(self._weights)
class RubricList(Rubric):
"""Container for dynamic lists of rubrics.
Analogous to nn.ModuleList. Does not define aggregation - use within
a parent rubric that implements custom logic.
Examples:
```python
class MultiGameRubric(Rubric):
def __init__(self, games: List[str]):
super().__init__()
self.games = RubricList([GameRubric(g) for g in games])
def forward(self, action, obs) -> float:
return self.games[obs.game_index](action, obs)
```
"""
def __init__(self, rubrics: List[Rubric] = None):
"""Initialize with optional list of rubrics.
Args:
rubrics (`list[``Rubric``]`, *optional*):
List of rubrics to start with.
"""
super().__init__()
self._rubrics: List[Rubric] = []
if rubrics is not None:
for i, rubric in enumerate(rubrics):
self.append(rubric)
def forward(self, action: Any, observation: Any) -> float:
"""RubricList does not define aggregation - override in parent."""
raise NotImplementedError(
"RubricList.forward() is not implemented. "
"Use RubricList within a parent rubric that defines aggregation."
)
def append(self, rubric: Rubric) -> None:
"""Add a rubric to the list."""
index = len(self._rubrics)
setattr(self, f"rubric_{index}", rubric)
self._rubrics.append(rubric)
def extend(self, rubrics: List[Rubric]) -> None:
"""Add multiple rubrics to the list."""
for rubric in rubrics:
self.append(rubric)
def __len__(self) -> int:
return len(self._rubrics)
def __getitem__(self, index: int) -> Rubric:
return self._rubrics[index]
def __iter__(self) -> Iterator[Rubric]:
return iter(self._rubrics)
class RubricDict(Rubric):
"""Container for named rubrics with keyed access.
Analogous to nn.ModuleDict. Enables keyed access for multi-task
environments where different tasks require different rubrics.
Examples:
```python
class AtariRubric(Rubric):
def __init__(self):
super().__init__()
self.games = RubricDict({
"pong": PongRubric(),
"breakout": BreakoutRubric(),
"space_invaders": SpaceInvadersRubric(),
})
def forward(self, action, obs) -> float:
return self.games[obs.game_id](action, obs)
# Access: env.rubric.games["pong"]
```
"""
def __init__(self, rubrics: Dict[str, Rubric] = None):
"""Initialize with optional dictionary of rubrics.
Args:
rubrics (`dict[str, ``Rubric``]`, *optional*):
Dictionary mapping names to rubrics.
"""
super().__init__()
self._rubric_dict: Dict[str, Rubric] = {}
if rubrics is not None:
for name, rubric in rubrics.items():
self[name] = rubric
def forward(self, action: Any, observation: Any) -> float:
"""RubricDict does not define aggregation - override in parent."""
raise NotImplementedError(
"RubricDict.forward() is not implemented. "
"Use RubricDict within a parent rubric that defines aggregation."
)
def __setitem__(self, key: str, rubric: Rubric) -> None:
"""Add a rubric with the given key."""
setattr(self, key, rubric)
self._rubric_dict[key] = rubric
def __getitem__(self, key: str) -> Rubric:
"""Get rubric by key."""
return self._rubric_dict[key]
def __contains__(self, key: str) -> bool:
"""Check if key exists."""
return key in self._rubric_dict
def __len__(self) -> int:
return len(self._rubric_dict)
def __iter__(self) -> Iterator[str]:
return iter(self._rubric_dict)
def keys(self) -> Iterator[str]:
"""Iterate over keys."""
return iter(self._rubric_dict.keys())
def values(self) -> Iterator[Rubric]:
"""Iterate over rubrics."""
return iter(self._rubric_dict.values())
def items(self) -> Iterator[Tuple[str, Rubric]]:
"""Iterate over (key, rubric) pairs."""
return iter(self._rubric_dict.items())
def update(self, rubrics: Union[Dict[str, Rubric], Mapping[str, Rubric]]) -> None:
"""Update with rubrics from a dictionary."""
for name, rubric in rubrics.items():
self[name] = rubric