Spaces:
Sleeping
Sleeping
Add application file
Browse files- Dockerfile +55 -0
- README.md +217 -7
- TUTORIAL.md +1845 -0
- __init__.py +1 -0
- __pycache__/__init__.cpython-311.pyc +0 -0
- __pycache__/baseline_inference.cpython-311.pyc +0 -0
- __pycache__/client.cpython-311.pyc +0 -0
- __pycache__/conftest.cpython-311-pytest-9.0.2.pyc +0 -0
- __pycache__/models.cpython-311.pyc +0 -0
- baseline_inference.py +251 -0
- client.py +140 -0
- conftest.py +5 -0
- hypothesis_lab.egg-info/PKG-INFO +243 -0
- hypothesis_lab.egg-info/SOURCES.txt +18 -0
- hypothesis_lab.egg-info/dependency_links.txt +1 -0
- hypothesis_lab.egg-info/requires.txt +15 -0
- hypothesis_lab.egg-info/top_level.txt +3 -0
- models.py +156 -0
- openenv.yaml +6 -0
- pyproject.toml +42 -0
- requirements.txt +9 -0
- server/__init__.py +1 -0
- server/__pycache__/__init__.cpython-311.pyc +0 -0
- server/__pycache__/app.cpython-311.pyc +0 -0
- server/__pycache__/causal_world.cpython-311.pyc +0 -0
- server/__pycache__/hypothesis_lab_environment.cpython-311.pyc +0 -0
- server/__pycache__/rubric.cpython-311.pyc +0 -0
- server/app.py +131 -0
- server/causal_world.py +474 -0
- server/hypothesis_lab_environment.py +363 -0
- server/rubric.py +326 -0
- tasks/__init__.py +17 -0
- tasks/__pycache__/__init__.cpython-311.pyc +0 -0
- tasks/__pycache__/task_easy.cpython-311.pyc +0 -0
- tasks/__pycache__/task_hard.cpython-311.pyc +0 -0
- tasks/__pycache__/task_medium.cpython-311.pyc +0 -0
- tasks/task_easy.py +57 -0
- tasks/task_hard.py +55 -0
- tasks/task_medium.py +49 -0
- test_docker.py +87 -0
- test_local.py +82 -0
- tests/__init__.py +0 -0
- tests/__pycache__/__init__.cpython-311.pyc +0 -0
- tests/__pycache__/test_environment.cpython-311-pytest-9.0.2.pyc +0 -0
- tests/__pycache__/test_environment.cpython-311.pyc +0 -0
- tests/test_environment.py +428 -0
- uv.lock +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
|
| 2 |
+
FROM ghcr.io/meta-pytorch/openenv-base:latest AS builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
RUN apt-get update && \
|
| 7 |
+
apt-get install -y --no-install-recommends git && \
|
| 8 |
+
rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
ARG BUILD_MODE=in-repo
|
| 11 |
+
ARG ENV_NAME=hypothesis_lab
|
| 12 |
+
|
| 13 |
+
COPY . /app/env
|
| 14 |
+
|
| 15 |
+
WORKDIR /app/env
|
| 16 |
+
|
| 17 |
+
RUN if ! command -v uv >/dev/null 2>&1; then \
|
| 18 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
| 19 |
+
mv /root/.local/bin/uv /usr/local/bin/uv && \
|
| 20 |
+
mv /root/.local/bin/uvx /usr/local/bin/uvx; \
|
| 21 |
+
fi
|
| 22 |
+
|
| 23 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 24 |
+
if [ -f uv.lock ]; then \
|
| 25 |
+
uv sync --frozen --no-install-project --no-editable; \
|
| 26 |
+
else \
|
| 27 |
+
uv sync --no-install-project --no-editable; \
|
| 28 |
+
fi
|
| 29 |
+
|
| 30 |
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 31 |
+
if [ -f uv.lock ]; then \
|
| 32 |
+
uv sync --frozen --no-editable; \
|
| 33 |
+
else \
|
| 34 |
+
uv sync --no-editable; \
|
| 35 |
+
fi
|
| 36 |
+
|
| 37 |
+
FROM ghcr.io/meta-pytorch/openenv-base:latest
|
| 38 |
+
|
| 39 |
+
WORKDIR /app
|
| 40 |
+
|
| 41 |
+
COPY --from=builder /app/env/.venv /app/.venv
|
| 42 |
+
COPY --from=builder /app/env /app/env
|
| 43 |
+
|
| 44 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 45 |
+
ENV PYTHONPATH="/app/env:$PYTHONPATH"
|
| 46 |
+
ENV PYTHONUNBUFFERED=1
|
| 47 |
+
|
| 48 |
+
EXPOSE 8000
|
| 49 |
+
|
| 50 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 51 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
| 52 |
+
|
| 53 |
+
ENV ENABLE_WEB_INTERFACE=true
|
| 54 |
+
|
| 55 |
+
CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
|
README.md
CHANGED
|
@@ -1,12 +1,222 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Scientific Hypothesis Lab
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
base_path: /web
|
| 10 |
+
tags:
|
| 11 |
+
- openenv
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Scientific Hypothesis Lab -- OpenEnv Environment
|
| 15 |
+
|
| 16 |
+
An RL environment where agents discover hidden causal rules through systematic
|
| 17 |
+
experimentation. Built for the [OpenEnv Hub](https://huggingface.co/openenv).
|
| 18 |
+
|
| 19 |
+
## What it does
|
| 20 |
+
|
| 21 |
+
Each episode, the agent is presented with a set of **abstract** variables
|
| 22 |
+
(e.g. Alpha, Beta, Gamma or V1, V2, V3) from a randomised causal world.
|
| 23 |
+
Variable names are deliberately opaque so agents cannot leverage pretrained
|
| 24 |
+
real-world knowledge -- they must reason purely from experimental evidence.
|
| 25 |
+
|
| 26 |
+
The hidden rules span **8 single-parent function types** (linear, threshold,
|
| 27 |
+
inverse, quadratic, exponential, logarithmic, saturating, piecewise-linear),
|
| 28 |
+
**multi-parent interaction rules** (additive, multiplicative, min, max), and
|
| 29 |
+
optional **hidden confounders** that inject unexplainable correlated noise.
|
| 30 |
+
|
| 31 |
+
The agent must:
|
| 32 |
+
|
| 33 |
+
1. **Design experiments** -- probe variable relationships using interventions,
|
| 34 |
+
correlations, counterfactuals, or passive observations
|
| 35 |
+
2. **Update beliefs** from noisy experimental results
|
| 36 |
+
3. **Submit a hypothesis** -- a structured description of the discovered causal rules
|
| 37 |
+
|
| 38 |
+
The environment rewards informative experiments, precise hypotheses, calibrated
|
| 39 |
+
confidence, and efficient budget use.
|
| 40 |
+
|
| 41 |
+
## Quick Start
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
# Install dependencies
|
| 45 |
+
pip install -e .
|
| 46 |
+
|
| 47 |
+
# Run the server locally
|
| 48 |
+
uvicorn server.app:app --port 8000
|
| 49 |
+
|
| 50 |
+
# In another terminal, run the baseline agent
|
| 51 |
+
export OPENAI_API_KEY=sk-...
|
| 52 |
+
python baseline_inference.py
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
### Using the Client
|
| 56 |
+
|
| 57 |
+
```python
|
| 58 |
+
from hypothesis_lab import HypothesisLabEnv, HypLabAction, ActionType
|
| 59 |
+
|
| 60 |
+
# Async usage
|
| 61 |
+
async with HypothesisLabEnv(base_url="http://localhost:8000") as env:
|
| 62 |
+
result = await env.reset(noise_level="low", domain="system_alpha")
|
| 63 |
+
obs = result.observation
|
| 64 |
+
|
| 65 |
+
# Run an intervention
|
| 66 |
+
result = await env.run_intervention(
|
| 67 |
+
control_variable=obs.available_variables[0],
|
| 68 |
+
control_value=5.0,
|
| 69 |
+
target_variable=obs.available_variables[1],
|
| 70 |
+
)
|
| 71 |
+
print(result.observation.system_message)
|
| 72 |
+
|
| 73 |
+
# Submit hypothesis
|
| 74 |
+
result = await env.submit_hypothesis(
|
| 75 |
+
hypothesis_text="Beta = 2.1 * Alpha + 3.0",
|
| 76 |
+
confidence=0.85,
|
| 77 |
+
)
|
| 78 |
+
print(f"Score: {result.observation.total_episode_reward}")
|
| 79 |
+
|
| 80 |
+
# Sync usage
|
| 81 |
+
env = HypothesisLabEnv(base_url="http://localhost:8000").sync()
|
| 82 |
+
with env:
|
| 83 |
+
result = env.reset(noise_level="low")
|
| 84 |
+
...
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## File Structure
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
hypothesis_lab/
|
| 91 |
+
├── openenv.yaml # OpenEnv manifest
|
| 92 |
+
├── pyproject.toml # Project metadata and dependencies
|
| 93 |
+
├── requirements.txt # Pip fallback dependencies
|
| 94 |
+
├── README.md # This file
|
| 95 |
+
├── models.py # Pydantic Action / Observation / State models
|
| 96 |
+
├── client.py # Typed EnvClient for agents and trainers
|
| 97 |
+
├── __init__.py # Module exports
|
| 98 |
+
├── baseline_inference.py # Baseline agent using OpenAI API
|
| 99 |
+
├── Dockerfile # For HF Spaces deployment
|
| 100 |
+
├── server/
|
| 101 |
+
│ ├── __init__.py
|
| 102 |
+
│ ├── app.py # FastAPI server (create_app entry point)
|
| 103 |
+
│ ├── hypothesis_lab_environment.py # Core environment logic
|
| 104 |
+
│ ├── causal_world.py # Hidden causal graph generator
|
| 105 |
+
│ └── rubric.py # Multi-component reward engine
|
| 106 |
+
├── tasks/
|
| 107 |
+
│ ├── __init__.py
|
| 108 |
+
│ ├── task_easy.py # Easy: 2 vars, low noise, 12 budget
|
| 109 |
+
│ ├── task_medium.py # Medium: 3 vars, medium noise, 10 budget
|
| 110 |
+
│ └── task_hard.py # Hard: 4 vars, high noise, 8 budget
|
| 111 |
+
└── tests/
|
| 112 |
+
├── __init__.py
|
| 113 |
+
└── test_environment.py # Unit + integration tests
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
## Action Space
|
| 117 |
+
|
| 118 |
+
**HypLabAction** has two modes:
|
| 119 |
+
|
| 120 |
+
| Field | Type | Description |
|
| 121 |
+
|---|---|---|
|
| 122 |
+
| `action_type` | `"experiment"` or `"submit"` | What the agent is doing |
|
| 123 |
+
| `experiment_type` | `"intervention"`, `"correlation"`, `"counterfactual"`, `"passive"` | Experiment kind (experiment mode) |
|
| 124 |
+
| `control_variable` | `str` | Variable to set/vary |
|
| 125 |
+
| `control_value` | `float` | Value to set (intervention/counterfactual) |
|
| 126 |
+
| `control_range` | `[min, max, n]` | Sweep range (correlation only) |
|
| 127 |
+
| `target_variable` | `str` | Variable to observe |
|
| 128 |
+
| `hypothesis_text` | `str` | Free-text hypothesis (submit mode) |
|
| 129 |
+
| `hypothesis_equations` | `list[str]` | Structured equations (submit mode) |
|
| 130 |
+
| `confidence` | `float [0,1]` | Self-reported confidence (submit mode) |
|
| 131 |
+
|
| 132 |
+
## Observation Space
|
| 133 |
+
|
| 134 |
+
**HypLabObservation** always contains:
|
| 135 |
+
- `system_message`: Human-readable text the LLM reads
|
| 136 |
+
- `available_variables`: Variable names in this episode
|
| 137 |
+
- `budget_remaining`: Steps left
|
| 138 |
+
- `done`: Whether episode ended
|
| 139 |
+
- `reward`: Step reward
|
| 140 |
+
|
| 141 |
+
On experiment steps: `result_value`, `noise_sigma`, `info_gain_reward`, `is_redundant`
|
| 142 |
+
|
| 143 |
+
On submit: `accuracy_score`, `precision_bonus`, `calibration_score`, `efficiency_bonus`, `contradiction_penalty`, `total_episode_reward`, `ground_truth_revealed`
|
| 144 |
+
|
| 145 |
+
## Causal Rule Types
|
| 146 |
+
|
| 147 |
+
The hidden world can contain any of these relationship types:
|
| 148 |
+
|
| 149 |
+
| Rule | Formula | Shape |
|
| 150 |
+
|---|---|---|
|
| 151 |
+
| Linear | `y = a*x + b` | Straight line |
|
| 152 |
+
| Threshold | `y = high if x > t else low` | Step function |
|
| 153 |
+
| Inverse | `y = a / x` | Hyperbola |
|
| 154 |
+
| Quadratic | `y = a*x² + b*x + c` | Parabola |
|
| 155 |
+
| Exponential | `y = a * exp(k*x)` | Growth/decay |
|
| 156 |
+
| Logarithmic | `y = a * ln(x) + b` | Diminishing returns |
|
| 157 |
+
| Saturating | `y = Vmax * x / (Km + x)` | Plateau (Michaelis-Menten) |
|
| 158 |
+
| Piecewise-linear | Two slopes with a knot | Regime change |
|
| 159 |
+
|
| 160 |
+
Additionally, some effects may depend on **two parents** via interaction rules
|
| 161 |
+
(additive, multiplicative, min, max), and **hidden confounders** may inject
|
| 162 |
+
correlated noise the agent cannot explain.
|
| 163 |
+
|
| 164 |
+
## Reward Components
|
| 165 |
+
|
| 166 |
+
| Signal | Value | What it trains |
|
| 167 |
+
|---|---|---|
|
| 168 |
+
| Information gain | +0.05 to +0.25/step | Designing informative experiments |
|
| 169 |
+
| Redundant experiment | -0.10 | Not wasting budget |
|
| 170 |
+
| Hypothesis accuracy | 0.0 to +1.0 | Getting the right answer |
|
| 171 |
+
| Precision bonus | +0.10 | Quantitative, falsifiable claims |
|
| 172 |
+
| Calibration score | 0.0 to +0.20 | Knowing what you don't know |
|
| 173 |
+
| Efficiency bonus | +0.15 | Submitting early when confident |
|
| 174 |
+
| Contradiction penalty | -0.50 | Contradicting the experimental setup |
|
| 175 |
+
|
| 176 |
+
## Tasks (3 difficulty levels)
|
| 177 |
+
|
| 178 |
+
| Task | Noise | Variables | Budget | Domain | Key Challenge |
|
| 179 |
+
|---|---|---|---|---|---|
|
| 180 |
+
| Easy | 0.05 | 2 | 12 | system_alpha | Single-edge discovery |
|
| 181 |
+
| Medium | 0.20 | 3 | 10 | Random | Multi-edge, noisy signals |
|
| 182 |
+
| Hard | 0.50 | 4 | 8 | Random | Complex graph + interactions, tight budget |
|
| 183 |
+
|
| 184 |
+
Each task has a deterministic grader that returns a score in [0.0, 1.0].
|
| 185 |
+
|
| 186 |
+
## Design Decisions
|
| 187 |
+
|
| 188 |
+
**Abstract variable names:** Variables are named Alpha, Beta, Gamma (or V1, V2,
|
| 189 |
+
V3, etc.) rather than Temperature, Pressure, Volume. This prevents LLM agents
|
| 190 |
+
from using pretrained knowledge of real-world physics/economics/biology to
|
| 191 |
+
shortcut the reasoning process. The agent must reason purely from experimental
|
| 192 |
+
data.
|
| 193 |
+
|
| 194 |
+
**Diverse rule types:** With 8 single-parent types plus interaction rules, the
|
| 195 |
+
agent cannot memorize a small set of templates. Many rule types look similar in
|
| 196 |
+
narrow ranges (e.g. exponential ≈ linear for small x), forcing the agent to
|
| 197 |
+
design discriminating experiments.
|
| 198 |
+
|
| 199 |
+
## Deploy to HF Spaces
|
| 200 |
+
|
| 201 |
+
```bash
|
| 202 |
+
openenv push --org your-org --token $HF_TOKEN
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
## Run Tests
|
| 206 |
+
|
| 207 |
+
```bash
|
| 208 |
+
pytest tests/ -v
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
## Baseline Scores
|
| 212 |
+
|
| 213 |
+
Baseline agent (gpt-4o-mini, temperature=0.3):
|
| 214 |
+
|
| 215 |
+
| Task | Score |
|
| 216 |
+
|---|---|
|
| 217 |
+
| Easy | ~0.65 |
|
| 218 |
+
| Medium | ~0.40 |
|
| 219 |
+
| Hard | ~0.25 |
|
| 220 |
+
| Average | ~0.43 |
|
| 221 |
+
|
| 222 |
+
These scores are reproducible via `python baseline_inference.py` with the same model and seed.
|
TUTORIAL.md
ADDED
|
@@ -0,0 +1,1845 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# The Complete Guide to Building RL Environments with OpenEnv
|
| 2 |
+
|
| 3 |
+
**A follow-along tutorial using the Scientific Hypothesis Lab**
|
| 4 |
+
|
| 5 |
+
By the end of this tutorial you will be able to:
|
| 6 |
+
- Explain what an RL environment is and why it matters
|
| 7 |
+
- Read and understand every file in this project
|
| 8 |
+
- Build your own OpenEnv environment from scratch
|
| 9 |
+
- Design reward functions that actually train good agents
|
| 10 |
+
- Deploy your environment to Hugging Face Spaces
|
| 11 |
+
- Explain all of this to anyone who asks
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## Table of Contents
|
| 16 |
+
|
| 17 |
+
1. [Part 1: The Big Picture](#part-1-the-big-picture)
|
| 18 |
+
2. [Part 2: The OpenEnv Contract](#part-2-the-openenv-contract)
|
| 19 |
+
3. [Part 3: Tour of Every File](#part-3-tour-of-every-file)
|
| 20 |
+
4. [Part 4: The Hidden World (causal_world.py)](#part-4-the-hidden-world)
|
| 21 |
+
5. [Part 5: The Reward Engine (rubric.py)](#part-5-the-reward-engine)
|
| 22 |
+
6. [Part 6: The Environment Core (hypothesis_lab_environment.py)](#part-6-the-environment-core)
|
| 23 |
+
7. [Part 7: The Data Models (models.py)](#part-7-the-data-models)
|
| 24 |
+
8. [Part 8: The Server (app.py)](#part-8-the-server)
|
| 25 |
+
9. [Part 9: The Client (client.py)](#part-9-the-client)
|
| 26 |
+
10. [Part 10: Tasks and Graders](#part-10-tasks-and-graders)
|
| 27 |
+
11. [Part 11: The Baseline Agent (baseline_inference.py)](#part-11-the-baseline-agent)
|
| 28 |
+
12. [Part 12: Testing](#part-12-testing)
|
| 29 |
+
13. [Part 13: Deployment](#part-13-deployment)
|
| 30 |
+
14. [Part 14: Hands-On Exercises](#part-14-hands-on-exercises)
|
| 31 |
+
15. [Part 15: Golden Rules for Building Environments](#part-15-golden-rules)
|
| 32 |
+
16. [Part 16: How to Build Your Own From Scratch](#part-16-build-your-own)
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## Part 1: The Big Picture
|
| 37 |
+
|
| 38 |
+
### What is Reinforcement Learning?
|
| 39 |
+
|
| 40 |
+
Imagine teaching a dog a trick. You can't explain the trick in English. Instead, you:
|
| 41 |
+
|
| 42 |
+
1. Let the dog **try something** (an action)
|
| 43 |
+
2. **Show it the result** (an observation)
|
| 44 |
+
3. Give it a **treat or a scolding** (a reward)
|
| 45 |
+
4. Repeat
|
| 46 |
+
|
| 47 |
+
The dog learns by trial and error. That's reinforcement learning (RL).
|
| 48 |
+
|
| 49 |
+
In RL, there are two players:
|
| 50 |
+
|
| 51 |
+
```
|
| 52 |
+
┌─────────┐ action ┌─────────────┐
|
| 53 |
+
│ AGENT │ ──────────> │ ENVIRONMENT │
|
| 54 |
+
│ (dog) │ <────────── │ (world) │
|
| 55 |
+
└─────────┘ observation └─────────────┘
|
| 56 |
+
+ reward
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
- **Agent**: the AI that learns (an LLM, a neural network, etc.)
|
| 60 |
+
- **Environment**: the world the agent lives in (our code!)
|
| 61 |
+
|
| 62 |
+
### What is an "Environment" in code?
|
| 63 |
+
|
| 64 |
+
An environment is a Python class with three methods:
|
| 65 |
+
|
| 66 |
+
```python
|
| 67 |
+
class MyEnvironment:
|
| 68 |
+
def reset(self):
|
| 69 |
+
"""Start a new episode. Return the first observation."""
|
| 70 |
+
...
|
| 71 |
+
|
| 72 |
+
def step(self, action):
|
| 73 |
+
"""Agent does something. Return what happened + reward."""
|
| 74 |
+
...
|
| 75 |
+
|
| 76 |
+
def state(self):
|
| 77 |
+
"""Return metadata about the current episode."""
|
| 78 |
+
...
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
That's it. Those three methods are the entire interface between the agent and the world.
|
| 82 |
+
|
| 83 |
+
### What is OpenEnv?
|
| 84 |
+
|
| 85 |
+
OpenEnv is a **standard** for RL environments. Think of it like USB for hardware -- it doesn't matter what device you plug in, as long as it follows the USB spec. OpenEnv says:
|
| 86 |
+
|
| 87 |
+
- Your `reset()` must return an `Observation` object
|
| 88 |
+
- Your `step()` must accept an `Action` object and return an `Observation`
|
| 89 |
+
- Your `state` must return a `State` object
|
| 90 |
+
- These objects must be Pydantic models (typed, validated Python objects)
|
| 91 |
+
- You must have an `openenv.yaml` manifest file
|
| 92 |
+
- You must serve your environment over HTTP (FastAPI)
|
| 93 |
+
|
| 94 |
+
Why bother with a standard? Because it means **any agent** can talk to **any environment** without custom glue code.
|
| 95 |
+
|
| 96 |
+
### What does OUR environment do?
|
| 97 |
+
|
| 98 |
+
Our environment is called the **Scientific Hypothesis Lab**. Here's the idea:
|
| 99 |
+
|
| 100 |
+
> The agent is a scientist. Each episode, it faces a hidden causal system
|
| 101 |
+
> (like "Beta = 2.0 * Alpha + 3.0"). The variables are **abstract** --
|
| 102 |
+
> named things like Alpha, Beta, Gamma or V1, V2, V3 -- so the agent
|
| 103 |
+
> can't rely on pretrained knowledge of real-world physics. It must
|
| 104 |
+
> reason purely from experimental data.
|
| 105 |
+
|
| 106 |
+
Think of it like a detective game:
|
| 107 |
+
- The "crime" is hidden causal rules between variables
|
| 108 |
+
- The "clues" are noisy experimental results
|
| 109 |
+
- The "solution" is a written hypothesis
|
| 110 |
+
- The "score" is how close the hypothesis matches reality
|
| 111 |
+
|
| 112 |
+
This is a **real-world** task -- it models how actual scientists discover causal relationships. Using abstract variable names ensures the agent genuinely *discovers* rules rather than recalling them from training data.
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## Part 2: The OpenEnv Contract
|
| 117 |
+
|
| 118 |
+
Before we look at code, let's understand the contract every OpenEnv environment must fulfill.
|
| 119 |
+
|
| 120 |
+
### The Three Methods
|
| 121 |
+
|
| 122 |
+
```
|
| 123 |
+
reset(**kwargs) -> Observation
|
| 124 |
+
"Start fresh. Generate a new puzzle. Tell the agent what it sees."
|
| 125 |
+
|
| 126 |
+
step(action: Action) -> Observation
|
| 127 |
+
"The agent did something. Process it. Tell the agent what happened."
|
| 128 |
+
|
| 129 |
+
state -> State (property, not a method call)
|
| 130 |
+
"Return metadata about the current episode. Never leak secrets."
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
### The Three Data Types
|
| 134 |
+
|
| 135 |
+
Every OpenEnv environment defines three Pydantic models that inherit from base types:
|
| 136 |
+
|
| 137 |
+
| Type | Base Class | Purpose | Who sees it |
|
| 138 |
+
|------|-----------|---------|-------------|
|
| 139 |
+
| **Action** | `openenv.core.Action` | What the agent sends | Agent -> Environment |
|
| 140 |
+
| **Observation** | `openenv.core.Observation` | What comes back | Environment -> Agent |
|
| 141 |
+
| **State** | `openenv.core.State` | Episode metadata | Anyone (debugging) |
|
| 142 |
+
|
| 143 |
+
The `Observation` base class always includes:
|
| 144 |
+
- `done: bool` -- is the episode over?
|
| 145 |
+
- `reward: float | None` -- how well did the agent do on this step?
|
| 146 |
+
|
| 147 |
+
The `State` base class always includes:
|
| 148 |
+
- `episode_id: str` -- unique ID for this episode
|
| 149 |
+
- `step_count: int` -- how many steps so far
|
| 150 |
+
|
| 151 |
+
### The Manifest (openenv.yaml)
|
| 152 |
+
|
| 153 |
+
Every environment needs a tiny YAML file:
|
| 154 |
+
|
| 155 |
+
```yaml
|
| 156 |
+
spec_version: 1 # Which version of the OpenEnv spec
|
| 157 |
+
name: hypothesis_lab # Machine-readable name
|
| 158 |
+
type: space # Deployed as an HF Space
|
| 159 |
+
runtime: fastapi # HTTP framework used
|
| 160 |
+
app: server.app:app # Python path to the ASGI app
|
| 161 |
+
port: 8000 # Port the server listens on
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
This is like a `package.json` for your environment -- it tells the OpenEnv tooling how to find and run your code.
|
| 165 |
+
|
| 166 |
+
### The Episode Lifecycle
|
| 167 |
+
|
| 168 |
+
Here's what one complete episode looks like:
|
| 169 |
+
|
| 170 |
+
```
|
| 171 |
+
1. Agent calls reset(noise_level="low", domain="system_alpha")
|
| 172 |
+
2. Environment generates a hidden world with random causal rules
|
| 173 |
+
3. Environment returns initial Observation (variable names, budget, instructions)
|
| 174 |
+
|
| 175 |
+
4. LOOP:
|
| 176 |
+
a. Agent reads the observation
|
| 177 |
+
b. Agent decides on an action (experiment or submit)
|
| 178 |
+
c. Agent calls step(action)
|
| 179 |
+
d. Environment processes the action
|
| 180 |
+
e. Environment returns new Observation (results, reward)
|
| 181 |
+
f. If observation.done == True, episode is over
|
| 182 |
+
|
| 183 |
+
5. Agent calls state to see final metadata
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
---
|
| 187 |
+
|
| 188 |
+
## Part 3: Tour of Every File
|
| 189 |
+
|
| 190 |
+
Here is every file and what it does. Think of this as the map before we explore each room.
|
| 191 |
+
|
| 192 |
+
```
|
| 193 |
+
hypothesis_lab/
|
| 194 |
+
│
|
| 195 |
+
├── openenv.yaml # THE MANIFEST
|
| 196 |
+
│ "Hi, I'm an OpenEnv environment. # Points the framework
|
| 197 |
+
│ Here's how to find my server." # to server.app:app
|
| 198 |
+
│
|
| 199 |
+
├── models.py # THE LANGUAGE
|
| 200 |
+
│ "These are the words the agent # HypLabAction
|
| 201 |
+
│ and environment use to talk." # HypLabObservation
|
| 202 |
+
│ # HypLabState
|
| 203 |
+
│
|
| 204 |
+
├── server/ # THE BRAIN
|
| 205 |
+
│ ├── app.py # HTTP server (thin wrapper)
|
| 206 |
+
│ ├── hypothesis_lab_environment.py # Core game logic
|
| 207 |
+
│ ├── causal_world.py # Hidden puzzle generator
|
| 208 |
+
│ └── rubric.py # Scoring engine
|
| 209 |
+
│
|
| 210 |
+
├── tasks/ # THE EXAM
|
| 211 |
+
│ ├── task_easy.py # Easy test + grader
|
| 212 |
+
│ ├── task_medium.py # Medium test + grader
|
| 213 |
+
│ └── task_hard.py # Hard test + grader
|
| 214 |
+
│
|
| 215 |
+
├── client.py # THE PHONE
|
| 216 |
+
│ "Typed Python client so agents # Wraps HTTP calls
|
| 217 |
+
│ don't need to speak raw HTTP." # into nice methods
|
| 218 |
+
│
|
| 219 |
+
├── baseline_inference.py # THE DEMO AGENT
|
| 220 |
+
│ "Here's a simple GPT agent that # Uses OpenAI API
|
| 221 |
+
│ can play the game. Not great, # Produces reproducible
|
| 222 |
+
│ but proves the game works." # scores on all 3 tasks
|
| 223 |
+
│
|
| 224 |
+
├── tests/ # THE SAFETY NET
|
| 225 |
+
│ └── test_environment.py # 39 tests covering
|
| 226 |
+
│ # every component
|
| 227 |
+
│
|
| 228 |
+
├── Dockerfile # THE SHIPPING BOX
|
| 229 |
+
│ "Packages everything into a # Multi-stage build
|
| 230 |
+
│ container for deployment." # OpenEnv base image
|
| 231 |
+
│
|
| 232 |
+
├── pyproject.toml # THE SHOPPING LIST
|
| 233 |
+
│ "What Python packages we need." # Dependencies + metadata
|
| 234 |
+
│
|
| 235 |
+
└── README.md # THE COVER LETTER
|
| 236 |
+
"What this environment is and # HF Spaces frontmatter
|
| 237 |
+
how to use it." # Action/observation docs
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
Now let's explore each room in detail.
|
| 241 |
+
|
| 242 |
+
---
|
| 243 |
+
|
| 244 |
+
## Part 4: The Hidden World
|
| 245 |
+
|
| 246 |
+
**File: `server/causal_world.py`**
|
| 247 |
+
|
| 248 |
+
This is the puzzle the agent must solve. Every episode generates a fresh hidden world.
|
| 249 |
+
|
| 250 |
+
### Core Concept: Causal Graphs
|
| 251 |
+
|
| 252 |
+
A causal graph is a set of variables connected by rules:
|
| 253 |
+
|
| 254 |
+
```
|
| 255 |
+
Alpha ──(quadratic)──> Beta ──(saturating)──> Gamma
|
| 256 |
+
7.93 B = 0.5*A² + 1.2 G = 10*B / (3 + B)
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
The agent never sees this graph. It can only probe it through experiments.
|
| 260 |
+
|
| 261 |
+
### Why Abstract Variable Names?
|
| 262 |
+
|
| 263 |
+
An earlier version of this environment used real-world names like "Temperature", "Pressure", "Volume". This created a serious problem: LLM agents have *pretrained knowledge* about how those variables relate (PV=nRT, supply/demand curves, etc.). The agent would use that prior knowledge instead of reasoning from experimental data -- which defeats the entire purpose.
|
| 264 |
+
|
| 265 |
+
Now variables are named things like **Alpha, Beta, Gamma** or **V1, V2, V3** or **Quant_A, Quant_B, Quant_C**. The LLM has no prior about how "Alpha" relates to "Beta", so it must genuinely discover the relationship through experiments.
|
| 266 |
+
|
| 267 |
+
### The Building Blocks
|
| 268 |
+
|
| 269 |
+
**CausalRule** -- one edge in the graph:
|
| 270 |
+
|
| 271 |
+
```python
|
| 272 |
+
@dataclass
|
| 273 |
+
class CausalRule:
|
| 274 |
+
cause: str # "Alpha"
|
| 275 |
+
effect: str # "Beta"
|
| 276 |
+
rule_type: str # one of 8 types (see table below)
|
| 277 |
+
params: dict # {"a": 2.1, "b": 3.0}
|
| 278 |
+
description: str # "Beta = 2.1 * Alpha + 3.0"
|
| 279 |
+
|
| 280 |
+
def evaluate(self, x: float) -> float:
|
| 281 |
+
# Given x (the cause value), compute the effect value
|
| 282 |
+
```
|
| 283 |
+
|
| 284 |
+
There are **eight** single-parent rule types:
|
| 285 |
+
|
| 286 |
+
| Rule | Formula | What it looks like | Why it's tricky |
|
| 287 |
+
|------|---------|-------------------|-----------------|
|
| 288 |
+
| Linear | `y = a*x + b` | Straight line | Easy to identify |
|
| 289 |
+
| Threshold | `y = high if x > t else low` | Step function | Need to find the cutoff |
|
| 290 |
+
| Inverse | `y = a / x` | Hyperbola | Blows up near zero |
|
| 291 |
+
| Quadratic | `y = a*x² + b*x + c` | Parabola | Looks linear in narrow range |
|
| 292 |
+
| Exponential | `y = a * exp(k*x)` | Growth/decay curve | Looks linear locally |
|
| 293 |
+
| Logarithmic | `y = a * ln(x) + b` | Diminishing returns | Looks linear in mid-range |
|
| 294 |
+
| Saturating | `y = Vmax * x / (Km + x)` | Plateau | Looks linear for small x |
|
| 295 |
+
| Piecewise-linear | Two slopes with a knot | Bent line | Looks linear on each side |
|
| 296 |
+
|
| 297 |
+
Many of these look similar with limited data. Quadratic, exponential, and saturating all resemble linear in a narrow range -- the agent must design experiments that *discriminate* between hypotheses (e.g., sampling at extremes to check for curvature).
|
| 298 |
+
|
| 299 |
+
**InteractionRule** -- a multi-parent edge where the effect depends on **two** causes:
|
| 300 |
+
|
| 301 |
+
```python
|
| 302 |
+
@dataclass
|
| 303 |
+
class InteractionRule:
|
| 304 |
+
cause1: str # "Alpha"
|
| 305 |
+
cause2: str # "Beta"
|
| 306 |
+
effect: str # "Gamma"
|
| 307 |
+
interaction_type: str # "additive", "multiplicative", "min", "max"
|
| 308 |
+
```
|
| 309 |
+
|
| 310 |
+
These are genuinely hard: the agent can't discover them by varying one variable at a time. It must realise that two parents jointly determine the effect.
|
| 311 |
+
|
| 312 |
+
**Try it yourself** -- open a Python shell in the project directory:
|
| 313 |
+
|
| 314 |
+
```python
|
| 315 |
+
from server.causal_world import CausalRule
|
| 316 |
+
|
| 317 |
+
rule = CausalRule(
|
| 318 |
+
cause="Alpha", effect="Beta",
|
| 319 |
+
rule_type="linear", params={"a": 2.0, "b": 3.0},
|
| 320 |
+
description="Beta = 2.0 * Alpha + 3.0"
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
print(rule.evaluate(0)) # 3.0 (y = 2*0 + 3)
|
| 324 |
+
print(rule.evaluate(5)) # 13.0 (y = 2*5 + 3)
|
| 325 |
+
print(rule.evaluate(10)) # 23.0 (y = 2*10 + 3)
|
| 326 |
+
|
| 327 |
+
# Try a saturating rule
|
| 328 |
+
sat = CausalRule(
|
| 329 |
+
cause="Alpha", effect="Beta",
|
| 330 |
+
rule_type="saturating", params={"v_max": 10.0, "k_m": 3.0},
|
| 331 |
+
description="Beta = 10 * Alpha / (3 + Alpha)"
|
| 332 |
+
)
|
| 333 |
+
print(sat.evaluate(1)) # 2.5 (still growing)
|
| 334 |
+
print(sat.evaluate(10)) # 7.69 (approaching plateau)
|
| 335 |
+
print(sat.evaluate(1000)) # ~10 (saturated)
|
| 336 |
+
```
|
| 337 |
+
|
| 338 |
+
### CausalWorld -- the full hidden system
|
| 339 |
+
|
| 340 |
+
The `CausalWorld` holds all the variables, rules, interaction rules, and default values. It also tracks a **confounder_sigma** -- if > 0, a hidden variable injects correlated noise the agent can't explain.
|
| 341 |
+
|
| 342 |
+
It has four query methods -- one for each experiment type the agent can run:
|
| 343 |
+
|
| 344 |
+
```python
|
| 345 |
+
world.query_intervention(cause, value, effect, sigma)
|
| 346 |
+
# "Set Alpha to 5.0. What does Beta become?" (+ noise + confounder)
|
| 347 |
+
|
| 348 |
+
world.query_correlation(cause, [1, 10, 5], effect, sigma)
|
| 349 |
+
# "Sweep Alpha from 1 to 10 in 5 steps. Show me Beta at each."
|
| 350 |
+
|
| 351 |
+
world.query_counterfactual(cause, delta, effect, sigma)
|
| 352 |
+
# "If Alpha increases by +3.0, what happens to Beta?"
|
| 353 |
+
|
| 354 |
+
world.query_passive(target, sigma)
|
| 355 |
+
# "Just show me what Beta is right now, without changing anything."
|
| 356 |
+
```
|
| 357 |
+
|
| 358 |
+
Every result has **Gaussian noise** added. If sigma=0.05, the noise is tiny (easy mode). If sigma=0.50, the noise is huge (hard mode). On top of that, ~27% of worlds also have hidden confounder noise.
|
| 359 |
+
|
| 360 |
+
**Try it yourself:**
|
| 361 |
+
|
| 362 |
+
```python
|
| 363 |
+
from server.causal_world import generate_world
|
| 364 |
+
|
| 365 |
+
world = generate_world(n_variables=3, domain="system_alpha", seed=42)
|
| 366 |
+
print("Variables:", world.variables)
|
| 367 |
+
print("Ground truth:")
|
| 368 |
+
print(world.ground_truth_summary())
|
| 369 |
+
|
| 370 |
+
# Check for interactions and confounders
|
| 371 |
+
print(f"\nInteraction rules: {len(world.interactions)}")
|
| 372 |
+
print(f"Confounder sigma: {world.confounder_sigma}")
|
| 373 |
+
|
| 374 |
+
# Run an experiment
|
| 375 |
+
cause, effect = world.variables[0], world.variables[1]
|
| 376 |
+
result = world.query_intervention(cause, 5.0, effect, sigma=0.05)
|
| 377 |
+
print(f"\nSet {cause}=5.0, observed {effect}={result:.4f}")
|
| 378 |
+
```
|
| 379 |
+
|
| 380 |
+
### The generate_world() Function
|
| 381 |
+
|
| 382 |
+
This is the factory that builds a fresh puzzle:
|
| 383 |
+
|
| 384 |
+
1. Pick a domain (system_alpha/beta/gamma/delta) -- this only changes the context prompt
|
| 385 |
+
2. Pick an abstract variable pool (Greek letters, V1-V5, Quant_A-E, etc.)
|
| 386 |
+
3. Choose N variables and connect them with random rules (8 possible types)
|
| 387 |
+
4. Add extra random edges with 30% probability
|
| 388 |
+
5. Optionally replace some single-parent rules with multi-parent interaction rules (~40% chance when n >= 3)
|
| 389 |
+
6. Optionally add a hidden confounder (~30% chance when n >= 3)
|
| 390 |
+
7. Compute default values for all variables
|
| 391 |
+
|
| 392 |
+
### Domains and Variable Pools
|
| 393 |
+
|
| 394 |
+
Domains provide different narrative prompts but use the same abstract variable names:
|
| 395 |
+
|
| 396 |
+
```python
|
| 397 |
+
DOMAIN_LABELS = {
|
| 398 |
+
"system_alpha": {"context": "You are studying an unknown dynamical system..."},
|
| 399 |
+
"system_beta": {"context": "You are investigating a black-box system..."},
|
| 400 |
+
"system_gamma": {"context": "You are analysing an opaque process..."},
|
| 401 |
+
"system_delta": {"context": "You are probing a simulated environment..."},
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
ABSTRACT_VAR_POOLS = [
|
| 405 |
+
["Alpha", "Beta", "Gamma", "Delta", "Epsilon"],
|
| 406 |
+
["Zeta", "Eta", "Theta", "Iota", "Kappa"],
|
| 407 |
+
["V1", "V2", "V3", "V4", "V5"],
|
| 408 |
+
["Rho", "Sigma", "Tau", "Upsilon", "Phi"],
|
| 409 |
+
# ... more pools
|
| 410 |
+
]
|
| 411 |
+
```
|
| 412 |
+
|
| 413 |
+
Each episode randomly selects a pool, so the agent can't even memorise variable-name-to-position mappings across episodes.
|
| 414 |
+
|
| 415 |
+
---
|
| 416 |
+
|
| 417 |
+
## Part 5: The Reward Engine
|
| 418 |
+
|
| 419 |
+
**File: `server/rubric.py`**
|
| 420 |
+
|
| 421 |
+
The reward function is arguably the most important part of any RL environment. A bad reward function trains bad agents. Let's understand every piece.
|
| 422 |
+
|
| 423 |
+
### Two Kinds of Rewards
|
| 424 |
+
|
| 425 |
+
Our environment gives rewards at two different times:
|
| 426 |
+
|
| 427 |
+
**Per-step rewards** (during the episode):
|
| 428 |
+
- Every experiment gives information gain reward
|
| 429 |
+
- Redundant experiments get penalized
|
| 430 |
+
|
| 431 |
+
**End-of-episode rewards** (when the agent submits its hypothesis):
|
| 432 |
+
- Accuracy, precision, calibration, efficiency, contradiction checks
|
| 433 |
+
|
| 434 |
+
### Per-Step: InfoGainTracker
|
| 435 |
+
|
| 436 |
+
This tracks which variable pairs (edges) the agent has probed:
|
| 437 |
+
|
| 438 |
+
```python
|
| 439 |
+
tracker = InfoGainTracker()
|
| 440 |
+
|
| 441 |
+
# First time probing Alpha -> Beta: +0.20
|
| 442 |
+
reward, redundant = tracker.record_and_score("Alpha", "Beta", "intervention", 5.0)
|
| 443 |
+
# reward = 0.20, redundant = False
|
| 444 |
+
|
| 445 |
+
# Second time, different experiment type (triangulation!): +0.25
|
| 446 |
+
reward, redundant = tracker.record_and_score("Alpha", "Beta", "correlation", [1,10,5])
|
| 447 |
+
# reward = 0.25, redundant = False (BONUS for using different experiment type!)
|
| 448 |
+
|
| 449 |
+
# Third time: only +0.05
|
| 450 |
+
# Fourth time: -0.10 (PENALTY)
|
| 451 |
+
```
|
| 452 |
+
|
| 453 |
+
The reward schedule:
|
| 454 |
+
|
| 455 |
+
| Visit # | Same type | Different type | Purpose |
|
| 456 |
+
|---------|-----------|---------------|---------|
|
| 457 |
+
| 1st | +0.20 | +0.20 | Reward exploration |
|
| 458 |
+
| 2nd | +0.12 | +0.25 | Reward triangulation |
|
| 459 |
+
| 3rd | +0.05 | +0.05 | Diminishing returns |
|
| 460 |
+
| 4th+ | -0.10 | -0.10 | Punish redundancy |
|
| 461 |
+
|
| 462 |
+
**Why this design?** In real science, repeating the exact same experiment is wasteful. But using a *different* method to study the same relationship (triangulation) is valuable because it confirms findings. Our reward function teaches the agent this lesson.
|
| 463 |
+
|
| 464 |
+
**Try it yourself:**
|
| 465 |
+
|
| 466 |
+
```python
|
| 467 |
+
from server.rubric import InfoGainTracker
|
| 468 |
+
|
| 469 |
+
tracker = InfoGainTracker()
|
| 470 |
+
for i in range(5):
|
| 471 |
+
reward, redundant = tracker.record_and_score("A", "B", "intervention", 1.0)
|
| 472 |
+
print(f"Visit {i+1}: reward={reward:+.2f}, redundant={redundant}")
|
| 473 |
+
|
| 474 |
+
print(f"\nCumulative info gain: {tracker.cumulative_gain:.2f}")
|
| 475 |
+
print(f"Redundant experiments: {tracker.redundant_count}")
|
| 476 |
+
```
|
| 477 |
+
|
| 478 |
+
### End-of-Episode: score_hypothesis()
|
| 479 |
+
|
| 480 |
+
When the agent submits, five scoring components fire:
|
| 481 |
+
|
| 482 |
+
#### 1. Accuracy Score (0.0 - 1.0)
|
| 483 |
+
|
| 484 |
+
How much of the ground truth did the agent discover?
|
| 485 |
+
|
| 486 |
+
For **single-parent rules**, the scorer checks:
|
| 487 |
+
- Did the hypothesis mention both the cause and effect variable names? (+0.4 per rule)
|
| 488 |
+
- Did it identify the relationship type (linear, quadratic, saturating, etc.)? (+0.3 per rule)
|
| 489 |
+
- Did it include the correct numerical parameters? (+0.3 per rule)
|
| 490 |
+
|
| 491 |
+
For **interaction rules**, the scorer checks:
|
| 492 |
+
- Did the hypothesis mention the effect and at least one cause? (+0.3)
|
| 493 |
+
- Did it mention both causes? (+0.2 additional)
|
| 494 |
+
- Did it identify the interaction type (additive, multiplicative, etc.)? (+0.5)
|
| 495 |
+
|
| 496 |
+
Example: if the ground truth is `Beta = 2.0 * Alpha + 3.0` and the agent writes "Beta increases linearly with Alpha at a slope of 2.0", it scores high on all three checks.
|
| 497 |
+
|
| 498 |
+
Each of the 8 rule types has its own set of keywords the scorer recognises (e.g. "saturating", "plateau", "asymptote" for saturating rules; "quadratic", "squared", "parabola" for quadratic).
|
| 499 |
+
|
| 500 |
+
#### 2. Precision Bonus (+0.10)
|
| 501 |
+
|
| 502 |
+
Does the hypothesis contain actual numbers? "Alpha affects Beta" scores 0. "Beta = 2.0 * Alpha + 3.0" scores +0.10. This rewards agents that make **falsifiable, quantitative claims** instead of vague hand-waving.
|
| 503 |
+
|
| 504 |
+
#### 3. Calibration Score (0.0 - 0.20)
|
| 505 |
+
|
| 506 |
+
When the agent submits, it also reports a confidence level (0.0 to 1.0). Calibration measures how well that confidence matches the actual accuracy:
|
| 507 |
+
|
| 508 |
+
```
|
| 509 |
+
calibration = 0.20 * (1 - |confidence - accuracy| / 0.5)
|
| 510 |
+
```
|
| 511 |
+
|
| 512 |
+
If the agent says confidence=0.9 but accuracy=0.2, that's overconfident and scores low. If confidence=0.3 and accuracy=0.2, that's well-calibrated and scores high. This teaches agents to **know what they don't know**.
|
| 513 |
+
|
| 514 |
+
#### 4. Efficiency Bonus (+0.15)
|
| 515 |
+
|
| 516 |
+
If the agent submits early (30%+ budget remaining) with decent accuracy (60%+), it gets a bonus. This rewards agents that don't waste time running unnecessary experiments.
|
| 517 |
+
|
| 518 |
+
#### 5. Contradiction Penalty (-0.50)
|
| 519 |
+
|
| 520 |
+
If the hypothesis contradicts the experimental setup (e.g., claiming "all variables are independent" or "no causal relationship exists"), it gets a harsh penalty. This teaches agents not to give up without trying.
|
| 521 |
+
|
| 522 |
+
**Try it yourself:**
|
| 523 |
+
|
| 524 |
+
```python
|
| 525 |
+
import numpy as np
|
| 526 |
+
from server.causal_world import CausalWorld, CausalRule
|
| 527 |
+
from server.rubric import score_hypothesis
|
| 528 |
+
|
| 529 |
+
rule = CausalRule("Alpha", "Beta", "linear",
|
| 530 |
+
{"a": 2.0, "b": 3.0},
|
| 531 |
+
"Beta = 2.0 * Alpha + 3.0")
|
| 532 |
+
|
| 533 |
+
world = CausalWorld(
|
| 534 |
+
domain="system_alpha",
|
| 535 |
+
variables=["Alpha", "Beta"],
|
| 536 |
+
units={"Alpha": "units", "Beta": "units"},
|
| 537 |
+
rules=[rule],
|
| 538 |
+
default_values={"Alpha": 5.0, "Beta": 13.0},
|
| 539 |
+
rng=np.random.default_rng(0),
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
# Good hypothesis
|
| 543 |
+
result = score_hypothesis(
|
| 544 |
+
"Beta = 2.0 * Alpha + 3.0. Linear relationship.",
|
| 545 |
+
["Beta = 2.0 * Alpha + 3.0"],
|
| 546 |
+
confidence=0.85,
|
| 547 |
+
world=world,
|
| 548 |
+
budget_remaining=4,
|
| 549 |
+
budget_total=10,
|
| 550 |
+
)
|
| 551 |
+
print(f"Accuracy: {result.accuracy_score:.2f}")
|
| 552 |
+
print(f"Precision: {result.precision_bonus:.2f}")
|
| 553 |
+
print(f"Calibration: {result.calibration_score:.2f}")
|
| 554 |
+
print(f"Efficiency: {result.efficiency_bonus:.2f}")
|
| 555 |
+
print(f"Contradiction:{result.contradiction_penalty:.2f}")
|
| 556 |
+
print(f"TOTAL: {result.total:.2f}")
|
| 557 |
+
print(f"\nFeedback: {result.feedback}")
|
| 558 |
+
```
|
| 559 |
+
|
| 560 |
+
---
|
| 561 |
+
|
| 562 |
+
## Part 6: The Environment Core
|
| 563 |
+
|
| 564 |
+
**File: `server/hypothesis_lab_environment.py`**
|
| 565 |
+
|
| 566 |
+
This is the central nervous system. It ties together the hidden world, the rubric, and the data models.
|
| 567 |
+
|
| 568 |
+
### The Class Structure
|
| 569 |
+
|
| 570 |
+
```python
|
| 571 |
+
class HypothesisLabEnvironment(Environment):
|
| 572 |
+
SUPPORTS_CONCURRENT_SESSIONS = True # Multiple agents can play at once
|
| 573 |
+
|
| 574 |
+
def __init__(self, **kwargs):
|
| 575 |
+
# Initialize empty state -- no episode running yet
|
| 576 |
+
self._world = None # The hidden causal graph
|
| 577 |
+
self._tracker = None # InfoGainTracker for per-step rewards
|
| 578 |
+
self._step_count = 0
|
| 579 |
+
self._budget_remaining = 0
|
| 580 |
+
self._done = True # No episode until reset() is called
|
| 581 |
+
self._history = [] # Log of all experiments
|
| 582 |
+
...
|
| 583 |
+
```
|
| 584 |
+
|
| 585 |
+
### reset() -- Starting a New Episode
|
| 586 |
+
|
| 587 |
+
```python
|
| 588 |
+
def reset(self, seed=None, episode_id=None, **kwargs):
|
| 589 |
+
# 1. Read difficulty parameters
|
| 590 |
+
noise_level = kwargs.get("noise_level", "medium") # low/medium/high
|
| 591 |
+
domain = kwargs.get("domain", None) # system_alpha/beta/gamma/delta
|
| 592 |
+
|
| 593 |
+
# 2. Look up noise and budget from schedule tables
|
| 594 |
+
sigma = NOISE_SCHEDULE[noise_level] # low=0.05, medium=0.20, high=0.50
|
| 595 |
+
budget = BUDGET_SCHEDULE[noise_level] # low=12, medium=10, high=8
|
| 596 |
+
n_vars = N_VARIABLES_SCHEDULE[noise_level] # low=2, medium=3, high=4
|
| 597 |
+
|
| 598 |
+
# 3. Generate a fresh hidden world (abstract variable names, 8+ rule types)
|
| 599 |
+
self._world = generate_world(n_variables=n_vars, domain=domain, seed=seed)
|
| 600 |
+
|
| 601 |
+
# 4. Initialize tracking
|
| 602 |
+
self._tracker = InfoGainTracker()
|
| 603 |
+
self._budget_remaining = budget
|
| 604 |
+
self._done = False
|
| 605 |
+
|
| 606 |
+
# 5. Return initial observation (variable names, budget, instructions)
|
| 607 |
+
return HypLabObservation(
|
| 608 |
+
system_message="New episode started. You have 3 unknown variables...",
|
| 609 |
+
available_variables=self._world.variables,
|
| 610 |
+
budget_remaining=budget,
|
| 611 |
+
done=False,
|
| 612 |
+
reward=0.0,
|
| 613 |
+
)
|
| 614 |
+
```
|
| 615 |
+
|
| 616 |
+
**Key insight:** `reset()` generates a *new* hidden world every time. The agent never carries knowledge between episodes. Each episode is an independent puzzle.
|
| 617 |
+
|
| 618 |
+
### step() -- Processing an Action
|
| 619 |
+
|
| 620 |
+
```python
|
| 621 |
+
def step(self, action: HypLabAction, **kwargs):
|
| 622 |
+
if self._done:
|
| 623 |
+
raise RuntimeError("Episode is done. Call reset().")
|
| 624 |
+
|
| 625 |
+
self._step_count += 1
|
| 626 |
+
|
| 627 |
+
if action.action_type == ActionType.EXPERIMENT:
|
| 628 |
+
return self._handle_experiment(action)
|
| 629 |
+
elif action.action_type == ActionType.SUBMIT:
|
| 630 |
+
return self._handle_submit(action)
|
| 631 |
+
```
|
| 632 |
+
|
| 633 |
+
There are only two things the agent can do: run an experiment, or submit a hypothesis. This is a **clean action space** -- no ambiguity about what actions are valid.
|
| 634 |
+
|
| 635 |
+
### _handle_experiment() -- Running an Experiment
|
| 636 |
+
|
| 637 |
+
This is the longest method. Here's what it does:
|
| 638 |
+
|
| 639 |
+
1. **Validate** the variable names (are they real variables in this world?)
|
| 640 |
+
2. **Route** to the right query method based on experiment type
|
| 641 |
+
3. **Format** the result as human-readable text (for the LLM to read)
|
| 642 |
+
4. **Score** the information gain via InfoGainTracker
|
| 643 |
+
5. **Deduct** budget
|
| 644 |
+
6. **Check** if budget is exhausted
|
| 645 |
+
7. **Return** observation with all the details
|
| 646 |
+
|
| 647 |
+
### _handle_submit() -- Grading the Hypothesis
|
| 648 |
+
|
| 649 |
+
1. Mark episode as done
|
| 650 |
+
2. Call `score_hypothesis()` from the rubric
|
| 651 |
+
3. Format the rubric breakdown as text
|
| 652 |
+
4. Return observation with scores and revealed ground truth
|
| 653 |
+
|
| 654 |
+
**Key insight:** the ground truth is only revealed **after** submission. This prevents the agent from cheating.
|
| 655 |
+
|
| 656 |
+
### state -- Episode Metadata
|
| 657 |
+
|
| 658 |
+
```python
|
| 659 |
+
@property
|
| 660 |
+
def state(self) -> HypLabState:
|
| 661 |
+
return HypLabState(
|
| 662 |
+
episode_id=self._episode_id,
|
| 663 |
+
step_count=self._step_count,
|
| 664 |
+
budget_remaining=self._budget_remaining,
|
| 665 |
+
noise_level=self._noise_level,
|
| 666 |
+
experiment_history=self._history, # What experiments ran so far
|
| 667 |
+
...
|
| 668 |
+
)
|
| 669 |
+
```
|
| 670 |
+
|
| 671 |
+
**Critical rule:** `state` must NEVER leak the hidden world. No rule types, no parameters, no ground truth. Only metadata the agent already knows.
|
| 672 |
+
|
| 673 |
+
**Try the full loop yourself:**
|
| 674 |
+
|
| 675 |
+
```python
|
| 676 |
+
from models import ActionType, ExperimentType, HypLabAction
|
| 677 |
+
from server.hypothesis_lab_environment import HypothesisLabEnvironment
|
| 678 |
+
|
| 679 |
+
env = HypothesisLabEnvironment()
|
| 680 |
+
|
| 681 |
+
# Start a new episode
|
| 682 |
+
obs = env.reset(seed=42, noise_level="low", domain="system_alpha")
|
| 683 |
+
print("=== RESET ===")
|
| 684 |
+
print(obs.system_message)
|
| 685 |
+
print()
|
| 686 |
+
|
| 687 |
+
# Run an experiment
|
| 688 |
+
vars_ = obs.available_variables
|
| 689 |
+
action = HypLabAction(
|
| 690 |
+
action_type=ActionType.EXPERIMENT,
|
| 691 |
+
experiment_type=ExperimentType.INTERVENTION,
|
| 692 |
+
control_variable=vars_[0],
|
| 693 |
+
target_variable=vars_[1],
|
| 694 |
+
control_value=5.0,
|
| 695 |
+
)
|
| 696 |
+
obs = env.step(action)
|
| 697 |
+
print("=== EXPERIMENT ===")
|
| 698 |
+
print(obs.system_message)
|
| 699 |
+
print(f"Info gain: {obs.info_gain_reward}")
|
| 700 |
+
print()
|
| 701 |
+
|
| 702 |
+
# Try a correlation sweep
|
| 703 |
+
action2 = HypLabAction(
|
| 704 |
+
action_type=ActionType.EXPERIMENT,
|
| 705 |
+
experiment_type=ExperimentType.CORRELATION,
|
| 706 |
+
control_variable=vars_[0],
|
| 707 |
+
control_range=[1.0, 10.0, 5.0],
|
| 708 |
+
target_variable=vars_[1],
|
| 709 |
+
)
|
| 710 |
+
obs = env.step(action2)
|
| 711 |
+
print("=== CORRELATION ===")
|
| 712 |
+
print(obs.system_message)
|
| 713 |
+
print()
|
| 714 |
+
|
| 715 |
+
# Submit hypothesis
|
| 716 |
+
submit = HypLabAction(
|
| 717 |
+
action_type=ActionType.SUBMIT,
|
| 718 |
+
hypothesis_text=f"{vars_[1]} is linearly related to {vars_[0]} with slope ~2.0",
|
| 719 |
+
hypothesis_equations=[f"{vars_[1]} = 2.0 * {vars_[0]} + 3.0"],
|
| 720 |
+
confidence=0.75,
|
| 721 |
+
)
|
| 722 |
+
obs = env.step(submit)
|
| 723 |
+
print("=== SUBMIT ===")
|
| 724 |
+
print(obs.system_message)
|
| 725 |
+
```
|
| 726 |
+
|
| 727 |
+
---
|
| 728 |
+
|
| 729 |
+
## Part 7: The Data Models
|
| 730 |
+
|
| 731 |
+
**File: `models.py`**
|
| 732 |
+
|
| 733 |
+
This file defines the *language* the agent and environment speak. Every piece of data that crosses the boundary must be one of these types.
|
| 734 |
+
|
| 735 |
+
### Why Pydantic?
|
| 736 |
+
|
| 737 |
+
Pydantic gives us:
|
| 738 |
+
1. **Validation** -- if the agent sends `control_value="hello"` instead of a number, it gets a clear error
|
| 739 |
+
2. **Serialization** -- objects convert to/from JSON automatically for HTTP transport
|
| 740 |
+
3. **Documentation** -- every field has a type and a description
|
| 741 |
+
4. **IDE support** -- autocomplete and type checking
|
| 742 |
+
|
| 743 |
+
### The Import Pattern
|
| 744 |
+
|
| 745 |
+
```python
|
| 746 |
+
try:
|
| 747 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 748 |
+
except ImportError:
|
| 749 |
+
# Fallback for when openenv-core isn't installed
|
| 750 |
+
from pydantic import BaseModel
|
| 751 |
+
class Action(BaseModel): ...
|
| 752 |
+
class Observation(BaseModel): ...
|
| 753 |
+
class State(BaseModel): ...
|
| 754 |
+
```
|
| 755 |
+
|
| 756 |
+
This pattern lets the code work both:
|
| 757 |
+
- In production (with openenv-core installed)
|
| 758 |
+
- In development/testing (without it)
|
| 759 |
+
|
| 760 |
+
### The Enums
|
| 761 |
+
|
| 762 |
+
```python
|
| 763 |
+
class ExperimentType(str, Enum):
|
| 764 |
+
INTERVENTION = "intervention"
|
| 765 |
+
CORRELATION = "correlation"
|
| 766 |
+
COUNTERFACTUAL = "counterfactual"
|
| 767 |
+
PASSIVE = "passive"
|
| 768 |
+
|
| 769 |
+
class ActionType(str, Enum):
|
| 770 |
+
EXPERIMENT = "experiment"
|
| 771 |
+
SUBMIT = "submit"
|
| 772 |
+
|
| 773 |
+
class NoiseLevelTag(str, Enum):
|
| 774 |
+
LOW = "low"
|
| 775 |
+
MEDIUM = "medium"
|
| 776 |
+
HIGH = "high"
|
| 777 |
+
```
|
| 778 |
+
|
| 779 |
+
Using `str, Enum` means these serialize as simple strings in JSON: `"intervention"` instead of `ExperimentType.INTERVENTION`. This makes the API friendly for LLM agents that output raw JSON.
|
| 780 |
+
|
| 781 |
+
### HypLabAction -- What the Agent Sends
|
| 782 |
+
|
| 783 |
+
The action model is **polymorphic** -- it handles two different use cases in one object:
|
| 784 |
+
|
| 785 |
+
```python
|
| 786 |
+
# Use case 1: Run an experiment
|
| 787 |
+
HypLabAction(
|
| 788 |
+
action_type="experiment",
|
| 789 |
+
experiment_type="intervention",
|
| 790 |
+
control_variable="Alpha",
|
| 791 |
+
control_value=5.0,
|
| 792 |
+
target_variable="Beta",
|
| 793 |
+
)
|
| 794 |
+
|
| 795 |
+
# Use case 2: Submit a hypothesis
|
| 796 |
+
HypLabAction(
|
| 797 |
+
action_type="submit",
|
| 798 |
+
hypothesis_text="Beta = 2.0 * Alpha + 3.0",
|
| 799 |
+
hypothesis_equations=["Beta = 2.0 * Alpha + 3.0"],
|
| 800 |
+
confidence=0.85,
|
| 801 |
+
)
|
| 802 |
+
```
|
| 803 |
+
|
| 804 |
+
The experiment fields are `Optional` so they can be `None` when submitting, and vice versa. This is a common pattern in RL environments where the action space has distinct modes.
|
| 805 |
+
|
| 806 |
+
### HypLabObservation -- What Comes Back
|
| 807 |
+
|
| 808 |
+
Observations are rich and multi-purpose:
|
| 809 |
+
|
| 810 |
+
- **Always present**: `system_message`, `available_variables`, `budget_remaining`, `done`, `reward`
|
| 811 |
+
- **After experiments**: `result_value`, `noise_sigma`, `info_gain_reward`, `is_redundant`
|
| 812 |
+
- **After submission**: `accuracy_score`, `total_episode_reward`, `ground_truth_revealed`
|
| 813 |
+
|
| 814 |
+
The `system_message` field is crucial -- it's the human-readable text that an LLM agent reads (e.g. "Set Alpha=5.0, observed Beta=13.04"). The structured fields are for programmatic access.
|
| 815 |
+
|
| 816 |
+
### HypLabState -- Episode Metadata
|
| 817 |
+
|
| 818 |
+
```python
|
| 819 |
+
class HypLabState(State):
|
| 820 |
+
budget_total: int = 0
|
| 821 |
+
budget_remaining: int = 0
|
| 822 |
+
noise_level: NoiseLevelTag = NoiseLevelTag.MEDIUM
|
| 823 |
+
experiment_history: list[dict] = []
|
| 824 |
+
cumulative_info_gain: float = 0.0
|
| 825 |
+
redundant_experiment_count: int = 0
|
| 826 |
+
```
|
| 827 |
+
|
| 828 |
+
Notice what's NOT here: no `rules`, no `default_values`, no `ground_truth`. The state is safe to show to the agent without leaking the answer.
|
| 829 |
+
|
| 830 |
+
---
|
| 831 |
+
|
| 832 |
+
## Part 8: The Server
|
| 833 |
+
|
| 834 |
+
**File: `server/app.py`**
|
| 835 |
+
|
| 836 |
+
This is the thinnest file in the project, and that's by design.
|
| 837 |
+
|
| 838 |
+
```python
|
| 839 |
+
from openenv.core.env_server.http_server import create_app
|
| 840 |
+
|
| 841 |
+
app = create_app(
|
| 842 |
+
HypothesisLabEnvironment, # The environment class
|
| 843 |
+
HypLabAction, # What the agent sends
|
| 844 |
+
HypLabObservation, # What comes back
|
| 845 |
+
env_name="hypothesis_lab",
|
| 846 |
+
max_concurrent_envs=200,
|
| 847 |
+
)
|
| 848 |
+
```
|
| 849 |
+
|
| 850 |
+
`create_app()` does all the heavy lifting:
|
| 851 |
+
- Creates FastAPI routes: `/reset`, `/step`, `/state`, `/health`, `/schema`
|
| 852 |
+
- Handles session management (multiple agents playing at once)
|
| 853 |
+
- Serializes/deserializes Pydantic models to/from JSON
|
| 854 |
+
- Adds WebSocket support for persistent connections
|
| 855 |
+
|
| 856 |
+
You almost never need to touch this file. The magic is in `create_app()`.
|
| 857 |
+
|
| 858 |
+
### The HTTP Endpoints
|
| 859 |
+
|
| 860 |
+
| Endpoint | Method | What it does |
|
| 861 |
+
|----------|--------|-------------|
|
| 862 |
+
| `/health` | GET | Returns `{"status": "ok"}` -- for Docker healthchecks |
|
| 863 |
+
| `/reset` | POST | Starts a new episode, returns initial observation |
|
| 864 |
+
| `/step` | POST | Sends an action, returns observation + reward |
|
| 865 |
+
| `/state` | GET | Returns current episode metadata |
|
| 866 |
+
| `/schema` | GET | Returns JSON schemas for Action/Observation |
|
| 867 |
+
|
| 868 |
+
### Running the Server
|
| 869 |
+
|
| 870 |
+
```bash
|
| 871 |
+
cd "files 2"
|
| 872 |
+
uvicorn server.app:app --port 8000
|
| 873 |
+
```
|
| 874 |
+
|
| 875 |
+
Then in another terminal:
|
| 876 |
+
|
| 877 |
+
```bash
|
| 878 |
+
curl http://localhost:8000/health
|
| 879 |
+
# {"status": "ok"}
|
| 880 |
+
|
| 881 |
+
curl -X POST http://localhost:8000/reset \
|
| 882 |
+
-H "Content-Type: application/json" \
|
| 883 |
+
-d '{"noise_level": "low", "domain": "system_alpha", "seed": 42}'
|
| 884 |
+
```
|
| 885 |
+
|
| 886 |
+
---
|
| 887 |
+
|
| 888 |
+
## Part 9: The Client
|
| 889 |
+
|
| 890 |
+
**File: `client.py`**
|
| 891 |
+
|
| 892 |
+
The client is the agent's friendly interface to the server. Instead of constructing raw HTTP requests, the agent gets nice typed methods.
|
| 893 |
+
|
| 894 |
+
```python
|
| 895 |
+
class HypothesisLabEnv(EnvClient[HypLabAction, HypLabObservation, HypLabState]):
|
| 896 |
+
```
|
| 897 |
+
|
| 898 |
+
The `EnvClient` base class handles:
|
| 899 |
+
- WebSocket connections (persistent, faster than HTTP polling)
|
| 900 |
+
- Automatic reconnection
|
| 901 |
+
- JSON serialization
|
| 902 |
+
|
| 903 |
+
Our client adds convenience methods:
|
| 904 |
+
|
| 905 |
+
```python
|
| 906 |
+
await env.run_intervention("Alpha", 5.0, "Beta")
|
| 907 |
+
await env.run_correlation("Alpha", [1, 10, 5], "Beta")
|
| 908 |
+
await env.run_counterfactual("Alpha", 3.0, "Beta")
|
| 909 |
+
await env.run_passive("Beta")
|
| 910 |
+
await env.submit_hypothesis("Beta = 2.0 * Alpha + 3.0", confidence=0.85)
|
| 911 |
+
```
|
| 912 |
+
|
| 913 |
+
Each method constructs the right `HypLabAction` internally so the agent doesn't have to remember the field names.
|
| 914 |
+
|
| 915 |
+
### The Three Abstract Methods
|
| 916 |
+
|
| 917 |
+
Every `EnvClient` subclass must implement:
|
| 918 |
+
|
| 919 |
+
```python
|
| 920 |
+
def _step_payload(self, action):
|
| 921 |
+
"""Convert a HypLabAction into a JSON-ready dict."""
|
| 922 |
+
return action.model_dump(exclude_none=True)
|
| 923 |
+
|
| 924 |
+
def _parse_result(self, payload):
|
| 925 |
+
"""Convert a JSON dict from the server into a StepResult."""
|
| 926 |
+
obs = HypLabObservation(**payload)
|
| 927 |
+
return StepResult(observation=obs, reward=..., done=...)
|
| 928 |
+
|
| 929 |
+
def _parse_state(self, payload):
|
| 930 |
+
"""Convert a JSON dict into a HypLabState."""
|
| 931 |
+
return HypLabState(**payload)
|
| 932 |
+
```
|
| 933 |
+
|
| 934 |
+
---
|
| 935 |
+
|
| 936 |
+
## Part 10: Tasks and Graders
|
| 937 |
+
|
| 938 |
+
**Files: `tasks/task_easy.py`, `task_medium.py`, `task_hard.py`**
|
| 939 |
+
|
| 940 |
+
The hackathon rules require **minimum 3 tasks** with **programmatic graders** that return scores between 0.0 and 1.0.
|
| 941 |
+
|
| 942 |
+
### What is a Task?
|
| 943 |
+
|
| 944 |
+
A task is a configuration dict that says "run the environment with these settings":
|
| 945 |
+
|
| 946 |
+
```python
|
| 947 |
+
TASK_EASY = {
|
| 948 |
+
"id": "easy",
|
| 949 |
+
"name": "Easy -- Single-Edge Discovery",
|
| 950 |
+
"description": "Discover the causal relationship between two abstract variables...",
|
| 951 |
+
"difficulty": "easy",
|
| 952 |
+
"reset_kwargs": {
|
| 953 |
+
"noise_level": "low", # sigma = 0.05
|
| 954 |
+
"domain": "system_alpha", # abstract domain
|
| 955 |
+
"seed": 42, # deterministic for reproducibility
|
| 956 |
+
},
|
| 957 |
+
}
|
| 958 |
+
```
|
| 959 |
+
|
| 960 |
+
### What is a Grader?
|
| 961 |
+
|
| 962 |
+
A grader takes the episode results and returns a normalized score:
|
| 963 |
+
|
| 964 |
+
```python
|
| 965 |
+
def grade_easy(episode_result: dict) -> float:
|
| 966 |
+
accuracy = episode_result.get("accuracy_score", 0.0)
|
| 967 |
+
efficiency = episode_result.get("efficiency_bonus", 0.0)
|
| 968 |
+
calibration = episode_result.get("calibration_score", 0.0)
|
| 969 |
+
|
| 970 |
+
raw = (
|
| 971 |
+
0.60 * min(accuracy, 1.0) # 60% weight on accuracy
|
| 972 |
+
+ 0.20 * min(efficiency / 0.15, 1.0) # 20% weight on efficiency
|
| 973 |
+
+ 0.20 * min(calibration / 0.20, 1.0) # 20% weight on calibration
|
| 974 |
+
)
|
| 975 |
+
|
| 976 |
+
return round(max(0.0, min(1.0, raw)), 4)
|
| 977 |
+
```
|
| 978 |
+
|
| 979 |
+
### Difficulty Progression
|
| 980 |
+
|
| 981 |
+
| | Easy | Medium | Hard |
|
| 982 |
+
|---|---|---|---|
|
| 983 |
+
| Variables | 2 | 3 | 4 |
|
| 984 |
+
| Noise (sigma) | 0.05 | 0.20 | 0.50 |
|
| 985 |
+
| Budget | 12 | 10 | 8 |
|
| 986 |
+
| Domain | system_alpha (fixed) | Random | Random |
|
| 987 |
+
| Key challenge | Single edge | Multiple edges + interactions | Complex graph + confounders + noise |
|
| 988 |
+
|
| 989 |
+
The hard task is genuinely hard for frontier models:
|
| 990 |
+
- 4 variables means up to 6 possible edges to discover
|
| 991 |
+
- Rules can be any of 8 types (not just linear!) plus interaction rules
|
| 992 |
+
- High noise + hidden confounders make every observation unreliable
|
| 993 |
+
- Only 8 experiments to figure it all out
|
| 994 |
+
- Abstract variable names prevent exploiting pretrained knowledge
|
| 995 |
+
|
| 996 |
+
**Try it yourself:**
|
| 997 |
+
|
| 998 |
+
```python
|
| 999 |
+
from tasks.task_easy import grade_easy
|
| 1000 |
+
|
| 1001 |
+
# Perfect episode
|
| 1002 |
+
score = grade_easy({
|
| 1003 |
+
"accuracy_score": 1.0,
|
| 1004 |
+
"efficiency_bonus": 0.15,
|
| 1005 |
+
"calibration_score": 0.20,
|
| 1006 |
+
})
|
| 1007 |
+
print(f"Perfect score: {score}") # 1.0
|
| 1008 |
+
|
| 1009 |
+
# Mediocre episode
|
| 1010 |
+
score = grade_easy({
|
| 1011 |
+
"accuracy_score": 0.4,
|
| 1012 |
+
"efficiency_bonus": 0.0,
|
| 1013 |
+
"calibration_score": 0.05,
|
| 1014 |
+
})
|
| 1015 |
+
print(f"Mediocre score: {score}") # ~0.29
|
| 1016 |
+
|
| 1017 |
+
# Zero effort
|
| 1018 |
+
score = grade_easy({})
|
| 1019 |
+
print(f"Zero score: {score}") # 0.0
|
| 1020 |
+
```
|
| 1021 |
+
|
| 1022 |
+
---
|
| 1023 |
+
|
| 1024 |
+
## Part 11: The Baseline Agent
|
| 1025 |
+
|
| 1026 |
+
**File: `baseline_inference.py`**
|
| 1027 |
+
|
| 1028 |
+
This script proves the environment works by running a real LLM agent against all three tasks.
|
| 1029 |
+
|
| 1030 |
+
### The Flow
|
| 1031 |
+
|
| 1032 |
+
```
|
| 1033 |
+
1. Create an OpenAI client (reads OPENAI_API_KEY from env)
|
| 1034 |
+
2. For each of the 3 tasks:
|
| 1035 |
+
a. Create a fresh HypothesisLabEnvironment
|
| 1036 |
+
b. Call reset() with the task's settings
|
| 1037 |
+
c. Enter a loop (max 8 turns):
|
| 1038 |
+
- Send the observation to the LLM as a "user" message
|
| 1039 |
+
- Parse the LLM's response into a HypLabAction
|
| 1040 |
+
- Call step(action)
|
| 1041 |
+
- If done, break
|
| 1042 |
+
d. If not done after 8 turns, force a submit
|
| 1043 |
+
e. Grade the episode with the task's grader
|
| 1044 |
+
3. Print all scores
|
| 1045 |
+
```
|
| 1046 |
+
|
| 1047 |
+
### The System Prompt
|
| 1048 |
+
|
| 1049 |
+
The system prompt teaches the LLM how to interact with the environment:
|
| 1050 |
+
|
| 1051 |
+
```
|
| 1052 |
+
You are a scientific AI assistant trained to discover hidden causal rules.
|
| 1053 |
+
...
|
| 1054 |
+
Format your actions as JSON:
|
| 1055 |
+
{"action_type": "experiment", "experiment_type": "intervention", ...}
|
| 1056 |
+
...
|
| 1057 |
+
Strategy tips:
|
| 1058 |
+
- Run interventions first to discover which variables are causally connected
|
| 1059 |
+
- Vary the control variable widely (e.g. 1, 5, 10) to detect nonlinearity
|
| 1060 |
+
- Don't repeat the same experiment -- redundant experiments are penalised
|
| 1061 |
+
```
|
| 1062 |
+
|
| 1063 |
+
### The Action Parser
|
| 1064 |
+
|
| 1065 |
+
LLMs don't always produce perfect JSON. The parser handles multiple formats:
|
| 1066 |
+
|
| 1067 |
+
1. **JSON in code blocks**: `` ```json {...} ``` ``
|
| 1068 |
+
2. **Raw JSON**: `{...}`
|
| 1069 |
+
3. **Natural language**: "I conclude that Beta = 2 * Alpha" (extracted via regex)
|
| 1070 |
+
4. **Timeout**: if it's the last turn, force a submit with whatever text the LLM wrote
|
| 1071 |
+
|
| 1072 |
+
### Running It
|
| 1073 |
+
|
| 1074 |
+
```bash
|
| 1075 |
+
export OPENAI_API_KEY=sk-...
|
| 1076 |
+
python baseline_inference.py
|
| 1077 |
+
```
|
| 1078 |
+
|
| 1079 |
+
Expected output:
|
| 1080 |
+
|
| 1081 |
+
```
|
| 1082 |
+
============================================================
|
| 1083 |
+
Scientific Hypothesis Lab -- Baseline Inference
|
| 1084 |
+
Model: gpt-4o-mini
|
| 1085 |
+
============================================================
|
| 1086 |
+
|
| 1087 |
+
--- Task: Easy -- Single-Edge Discovery ---
|
| 1088 |
+
Total episode reward: +0.6100
|
| 1089 |
+
Graded score: 0.6500
|
| 1090 |
+
|
| 1091 |
+
--- Task: Medium -- Multi-Edge Discovery ---
|
| 1092 |
+
Total episode reward: +0.3800
|
| 1093 |
+
Graded score: 0.4000
|
| 1094 |
+
|
| 1095 |
+
--- Task: Hard -- Complex Graph Under Noise ---
|
| 1096 |
+
Total episode reward: +0.2100
|
| 1097 |
+
Graded score: 0.2500
|
| 1098 |
+
|
| 1099 |
+
============================================================
|
| 1100 |
+
SUMMARY
|
| 1101 |
+
============================================================
|
| 1102 |
+
easy : 0.6500
|
| 1103 |
+
medium : 0.4000
|
| 1104 |
+
hard : 0.2500
|
| 1105 |
+
average : 0.4333
|
| 1106 |
+
```
|
| 1107 |
+
|
| 1108 |
+
---
|
| 1109 |
+
|
| 1110 |
+
## Part 12: Testing
|
| 1111 |
+
|
| 1112 |
+
**File: `tests/test_environment.py`**
|
| 1113 |
+
|
| 1114 |
+
39 tests organized into 5 test classes. Run them with:
|
| 1115 |
+
|
| 1116 |
+
```bash
|
| 1117 |
+
pytest tests/ -v
|
| 1118 |
+
```
|
| 1119 |
+
|
| 1120 |
+
### Test Classes
|
| 1121 |
+
|
| 1122 |
+
| Class | Tests | What it covers |
|
| 1123 |
+
|-------|-------|----------------|
|
| 1124 |
+
| TestCausalWorld | 18 | World generation, all 8 rule types, interactions, domains, seeds, abstract names |
|
| 1125 |
+
| TestInfoGainTracker | 4 | Reward schedule, redundancy, triangulation |
|
| 1126 |
+
| TestRubric | 6 | Accuracy scoring, calibration, efficiency, feedback |
|
| 1127 |
+
| TestEnvironmentIntegration | 6 | Full episodes, budget exhaustion, errors, state leaks |
|
| 1128 |
+
| TestGraders | 5 | Grader range [0,1], zero input, perfect input |
|
| 1129 |
+
|
| 1130 |
+
### Key Tests to Study
|
| 1131 |
+
|
| 1132 |
+
**Seed reproducibility** -- same seed produces same world:
|
| 1133 |
+
```python
|
| 1134 |
+
world1 = generate_world(n_variables=3, domain="system_alpha", seed=99)
|
| 1135 |
+
world2 = generate_world(n_variables=3, domain="system_alpha", seed=99)
|
| 1136 |
+
assert world1.variables == world2.variables
|
| 1137 |
+
```
|
| 1138 |
+
|
| 1139 |
+
**Variable names are abstract** -- no real-world names that give LLMs prior knowledge:
|
| 1140 |
+
```python
|
| 1141 |
+
for seed in range(50):
|
| 1142 |
+
world = generate_world(n_variables=4, seed=seed)
|
| 1143 |
+
for v in world.variables:
|
| 1144 |
+
assert v.lower() not in {"temperature", "pressure", "price", ...}
|
| 1145 |
+
```
|
| 1146 |
+
|
| 1147 |
+
**State doesn't leak secrets**:
|
| 1148 |
+
```python
|
| 1149 |
+
st = env.state
|
| 1150 |
+
state_str = str(st.model_dump())
|
| 1151 |
+
assert "rule_type" not in state_str
|
| 1152 |
+
assert "params" not in state_str
|
| 1153 |
+
```
|
| 1154 |
+
|
| 1155 |
+
**Diverse rule types over many seeds** -- we see all 8+ types:
|
| 1156 |
+
```python
|
| 1157 |
+
types_seen = set()
|
| 1158 |
+
for seed in range(100):
|
| 1159 |
+
world = generate_world(n_variables=3, seed=seed)
|
| 1160 |
+
for rule in world.rules:
|
| 1161 |
+
types_seen.add(rule.rule_type)
|
| 1162 |
+
assert len(types_seen) >= 5
|
| 1163 |
+
```
|
| 1164 |
+
|
| 1165 |
+
**Grader always returns [0, 1]**:
|
| 1166 |
+
```python
|
| 1167 |
+
score = grade_easy({"accuracy_score": 1.0, "efficiency_bonus": 0.15, ...})
|
| 1168 |
+
assert 0.0 <= score <= 1.0
|
| 1169 |
+
```
|
| 1170 |
+
|
| 1171 |
+
---
|
| 1172 |
+
|
| 1173 |
+
## Part 13: Deployment
|
| 1174 |
+
|
| 1175 |
+
### Dockerfile
|
| 1176 |
+
|
| 1177 |
+
The Dockerfile uses a multi-stage build:
|
| 1178 |
+
|
| 1179 |
+
```
|
| 1180 |
+
Stage 1 (builder):
|
| 1181 |
+
- Start from OpenEnv base image
|
| 1182 |
+
- Copy source code
|
| 1183 |
+
- Install uv (Python package manager)
|
| 1184 |
+
- Run uv sync to install dependencies
|
| 1185 |
+
- This creates a .venv with all packages
|
| 1186 |
+
|
| 1187 |
+
Stage 2 (runtime):
|
| 1188 |
+
- Start from a clean base image
|
| 1189 |
+
- Copy only the .venv and source code (not build tools)
|
| 1190 |
+
- Set PATH and PYTHONPATH
|
| 1191 |
+
- Run uvicorn to start the server
|
| 1192 |
+
```
|
| 1193 |
+
|
| 1194 |
+
### Step 1: Build the Docker Image
|
| 1195 |
+
|
| 1196 |
+
```bash
|
| 1197 |
+
cd Lab-experiment
|
| 1198 |
+
docker build -t hypothesis-lab .
|
| 1199 |
+
```
|
| 1200 |
+
|
| 1201 |
+
This takes 2-5 minutes the first time (downloads base image + installs dependencies). Subsequent builds are fast thanks to layer caching. You should see `Successfully tagged hypothesis-lab:latest` at the end.
|
| 1202 |
+
|
| 1203 |
+
If the build fails, check:
|
| 1204 |
+
- `pyproject.toml` has `build-backend = "setuptools.build_meta"` (not the experimental `setuptools.backends` path)
|
| 1205 |
+
- `.dockerignore` excludes `.venv/`, `__pycache__/`, `.git/`
|
| 1206 |
+
|
| 1207 |
+
### Step 2: Run the Container
|
| 1208 |
+
|
| 1209 |
+
```bash
|
| 1210 |
+
docker run -p 8000:8000 hypothesis-lab
|
| 1211 |
+
```
|
| 1212 |
+
|
| 1213 |
+
You should see uvicorn start up:
|
| 1214 |
+
|
| 1215 |
+
```
|
| 1216 |
+
INFO: Started server process [1]
|
| 1217 |
+
INFO: Waiting for application startup.
|
| 1218 |
+
INFO: Application startup complete.
|
| 1219 |
+
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
| 1220 |
+
```
|
| 1221 |
+
|
| 1222 |
+
To run in the background (detached mode):
|
| 1223 |
+
|
| 1224 |
+
```bash
|
| 1225 |
+
docker run -d --name hyp-lab -p 8000:8000 hypothesis-lab
|
| 1226 |
+
```
|
| 1227 |
+
|
| 1228 |
+
### Step 3: Verify the Server is Running
|
| 1229 |
+
|
| 1230 |
+
Open a **new terminal** and run:
|
| 1231 |
+
|
| 1232 |
+
```bash
|
| 1233 |
+
curl http://localhost:8000/health
|
| 1234 |
+
```
|
| 1235 |
+
|
| 1236 |
+
Expected response:
|
| 1237 |
+
|
| 1238 |
+
```json
|
| 1239 |
+
{"status":"ok"}
|
| 1240 |
+
```
|
| 1241 |
+
|
| 1242 |
+
### Step 4: Check the API Schema
|
| 1243 |
+
|
| 1244 |
+
```bash
|
| 1245 |
+
curl -s http://localhost:8000/schema | python3 -m json.tool
|
| 1246 |
+
```
|
| 1247 |
+
|
| 1248 |
+
This returns the JSON Schema definitions for `HypLabAction` and `HypLabObservation`, useful for understanding what fields exist.
|
| 1249 |
+
|
| 1250 |
+
### Step 5: Understand HTTP vs WebSocket
|
| 1251 |
+
|
| 1252 |
+
> **Critical concept:** The OpenEnv server has two communication modes:
|
| 1253 |
+
>
|
| 1254 |
+
> | Endpoint | Type | Stateful? | Use case |
|
| 1255 |
+
> |---|---|---|---|
|
| 1256 |
+
> | `/health` | GET | No | Check if server is alive |
|
| 1257 |
+
> | `/schema` | GET | No | Inspect action/observation schemas |
|
| 1258 |
+
> | `/reset` | POST | **No** -- creates a fresh env, returns result, destroys env | One-shot inspection |
|
| 1259 |
+
> | `/step` | POST | **No** -- creates a fresh env (never reset!), tries to step, fails | **Don't use for episodes** |
|
| 1260 |
+
> | `/ws` | WebSocket | **Yes** -- persistent connection, one env for the whole episode | **Use this for episodes** |
|
| 1261 |
+
>
|
| 1262 |
+
> The HTTP `/reset` and `/step` are **stateless**: each request creates a brand-new
|
| 1263 |
+
> environment instance and destroys it after responding. If you `curl /reset` then
|
| 1264 |
+
> `curl /step`, the step hits a *different* environment that was never reset -- so
|
| 1265 |
+
> it fails. Multi-step episodes require the **WebSocket** endpoint (`/ws`), which
|
| 1266 |
+
> keeps one environment alive for the entire connection.
|
| 1267 |
+
|
| 1268 |
+
This is why `curl` to `/step` returned an empty response -- the server-side
|
| 1269 |
+
environment had no world to step in. Our environment now returns a clear error
|
| 1270 |
+
instead of crashing:
|
| 1271 |
+
|
| 1272 |
+
```json
|
| 1273 |
+
{"observation": {"system_message": "Error: No active episode. Call reset() first.", "done": true, "reward": -1.0}, ...}
|
| 1274 |
+
```
|
| 1275 |
+
|
| 1276 |
+
### Step 6: Run a Full Episode (Python script)
|
| 1277 |
+
|
| 1278 |
+
The proper way to interact is via WebSocket. The `EnvClient` class handles
|
| 1279 |
+
this automatically. Save this as `test_docker.py` and run it while the
|
| 1280 |
+
container is running:
|
| 1281 |
+
|
| 1282 |
+
```python
|
| 1283 |
+
import asyncio
|
| 1284 |
+
import json
|
| 1285 |
+
import websockets
|
| 1286 |
+
|
| 1287 |
+
async def run_episode():
|
| 1288 |
+
uri = "ws://localhost:8000/ws"
|
| 1289 |
+
async with websockets.connect(uri) as ws:
|
| 1290 |
+
|
| 1291 |
+
# 1. Reset
|
| 1292 |
+
await ws.send(json.dumps({
|
| 1293 |
+
"type": "reset",
|
| 1294 |
+
"data": {"noise_level": "low", "domain": "system_alpha", "seed": 42}
|
| 1295 |
+
}))
|
| 1296 |
+
resp = json.loads(await ws.recv())
|
| 1297 |
+
obs = resp["data"]["observation"]
|
| 1298 |
+
print(f"=== Episode Started ===")
|
| 1299 |
+
print(f"Variables: {obs['available_variables']}")
|
| 1300 |
+
print(f"Budget: {obs['budget_remaining']}")
|
| 1301 |
+
print()
|
| 1302 |
+
|
| 1303 |
+
variables = obs["available_variables"]
|
| 1304 |
+
cause, effect = variables[0], variables[1]
|
| 1305 |
+
|
| 1306 |
+
# 2. Intervention experiment
|
| 1307 |
+
await ws.send(json.dumps({
|
| 1308 |
+
"type": "step",
|
| 1309 |
+
"data": {
|
| 1310 |
+
"action_type": "experiment",
|
| 1311 |
+
"experiment_type": "intervention",
|
| 1312 |
+
"control_variable": cause,
|
| 1313 |
+
"control_value": 5.0,
|
| 1314 |
+
"target_variable": effect,
|
| 1315 |
+
}
|
| 1316 |
+
}))
|
| 1317 |
+
resp = json.loads(await ws.recv())
|
| 1318 |
+
obs = resp["data"]["observation"]
|
| 1319 |
+
print(f"[Intervention] Set {cause}=5.0 -> {effect}={obs['result_value']}")
|
| 1320 |
+
print(f" Info gain: {obs['info_gain_reward']}, Budget left: {obs['budget_remaining']}")
|
| 1321 |
+
print()
|
| 1322 |
+
|
| 1323 |
+
# 3. Correlation sweep
|
| 1324 |
+
await ws.send(json.dumps({
|
| 1325 |
+
"type": "step",
|
| 1326 |
+
"data": {
|
| 1327 |
+
"action_type": "experiment",
|
| 1328 |
+
"experiment_type": "correlation",
|
| 1329 |
+
"control_variable": cause,
|
| 1330 |
+
"control_range": [0.5, 20.0, 8],
|
| 1331 |
+
"target_variable": effect,
|
| 1332 |
+
}
|
| 1333 |
+
}))
|
| 1334 |
+
resp = json.loads(await ws.recv())
|
| 1335 |
+
obs = resp["data"]["observation"]
|
| 1336 |
+
print(f"[Correlation] Swept {cause} from 0.5 to 20.0:")
|
| 1337 |
+
if isinstance(obs["result_value"], list):
|
| 1338 |
+
for point in obs["result_value"]:
|
| 1339 |
+
print(f" {cause}={point[0]:.1f} -> {effect}={point[1]:.4f}")
|
| 1340 |
+
print(f" Info gain: {obs['info_gain_reward']}, Budget left: {obs['budget_remaining']}")
|
| 1341 |
+
print()
|
| 1342 |
+
|
| 1343 |
+
# 4. Submit hypothesis
|
| 1344 |
+
await ws.send(json.dumps({
|
| 1345 |
+
"type": "step",
|
| 1346 |
+
"data": {
|
| 1347 |
+
"action_type": "submit",
|
| 1348 |
+
"hypothesis_text": f"{effect} depends linearly on {cause}.",
|
| 1349 |
+
"hypothesis_equations": [f"{effect} = 2.0 * {cause} + 1.0"],
|
| 1350 |
+
"confidence": 0.6,
|
| 1351 |
+
}
|
| 1352 |
+
}))
|
| 1353 |
+
resp = json.loads(await ws.recv())
|
| 1354 |
+
obs = resp["data"]["observation"]
|
| 1355 |
+
print(f"=== Episode Finished ===")
|
| 1356 |
+
print(f"Accuracy: {obs.get('accuracy_score')}")
|
| 1357 |
+
print(f"Precision: {obs.get('precision_bonus')}")
|
| 1358 |
+
print(f"Calibration: {obs.get('calibration_score')}")
|
| 1359 |
+
print(f"Efficiency: {obs.get('efficiency_bonus')}")
|
| 1360 |
+
print(f"Contradiction: {obs.get('contradiction_penalty')}")
|
| 1361 |
+
print(f"TOTAL REWARD: {obs.get('total_episode_reward')}")
|
| 1362 |
+
print()
|
| 1363 |
+
print(f"Ground truth:\n{obs.get('ground_truth_revealed')}")
|
| 1364 |
+
|
| 1365 |
+
asyncio.run(run_episode())
|
| 1366 |
+
```
|
| 1367 |
+
|
| 1368 |
+
Run it:
|
| 1369 |
+
|
| 1370 |
+
```bash
|
| 1371 |
+
pip install websockets # one-time install
|
| 1372 |
+
python test_docker.py
|
| 1373 |
+
```
|
| 1374 |
+
|
| 1375 |
+
Expected output:
|
| 1376 |
+
|
| 1377 |
+
```
|
| 1378 |
+
=== Episode Started ===
|
| 1379 |
+
Variables: ['Quant_A', 'Quant_E']
|
| 1380 |
+
Budget: 12
|
| 1381 |
+
|
| 1382 |
+
[Intervention] Set Quant_A=5.0 -> Quant_E=3.4521
|
| 1383 |
+
Info gain: 0.12, Budget left: 11
|
| 1384 |
+
|
| 1385 |
+
[Correlation] Swept Quant_A from 0.5 to 20.0:
|
| 1386 |
+
Quant_A=0.5 -> Quant_E=7.8123
|
| 1387 |
+
Quant_A=3.3 -> Quant_E=4.2341
|
| 1388 |
+
...
|
| 1389 |
+
Info gain: 0.10, Budget left: 10
|
| 1390 |
+
|
| 1391 |
+
=== Episode Finished ===
|
| 1392 |
+
Accuracy: 0.35
|
| 1393 |
+
Precision: 0.0
|
| 1394 |
+
Calibration: 0.14
|
| 1395 |
+
Efficiency: 0.15
|
| 1396 |
+
Contradiction: 0.0
|
| 1397 |
+
TOTAL REWARD: 0.86
|
| 1398 |
+
|
| 1399 |
+
Ground truth:
|
| 1400 |
+
Domain: system_alpha
|
| 1401 |
+
Quant_E = 1.11 * exp(-0.16 * Quant_A)
|
| 1402 |
+
```
|
| 1403 |
+
|
| 1404 |
+
> **Key insight from the WebSocket protocol:**
|
| 1405 |
+
>
|
| 1406 |
+
> - Send messages as `{"type": "reset", "data": {...}}` and `{"type": "step", "data": {...}}`
|
| 1407 |
+
> - The action fields go directly inside `"data"` (no extra `"action"` wrapper)
|
| 1408 |
+
> - Responses come back as `{"type": "observation", "data": {"observation": {...}, "reward": ..., "done": ...}}`
|
| 1409 |
+
> - The observation fields live at `resp["data"]["observation"]` -- note the double nesting
|
| 1410 |
+
|
| 1411 |
+
### Understanding the Observation Fields
|
| 1412 |
+
|
| 1413 |
+
On reset, most fields are `null` -- only setup information is populated:
|
| 1414 |
+
|
| 1415 |
+
| Field | What it tells you |
|
| 1416 |
+
|---|---|
|
| 1417 |
+
| `system_message` | Human-readable summary -- the LLM agent reads this |
|
| 1418 |
+
| `available_variables` | Variable names to use in experiments |
|
| 1419 |
+
| `budget_remaining` | Number of experiment steps left |
|
| 1420 |
+
| `result_value` | `null` on reset; float or `[[x,y],...]` list after experiments |
|
| 1421 |
+
| `noise_sigma` | `null` on reset; shown per-experiment so you know measurement precision |
|
| 1422 |
+
| `done` | `false` until you submit or budget runs out |
|
| 1423 |
+
| `reward` | Reward for this step (0.0 on reset) |
|
| 1424 |
+
| `accuracy_score` ... `ground_truth_revealed` | All `null` until you submit your hypothesis |
|
| 1425 |
+
|
| 1426 |
+
After submit, the scoring fields light up:
|
| 1427 |
+
|
| 1428 |
+
| Field | Meaning |
|
| 1429 |
+
|---|---|
|
| 1430 |
+
| `accuracy_score` | How close your hypothesis matches the true rules (0-1) |
|
| 1431 |
+
| `precision_bonus` | Bonus for getting coefficients/parameters right |
|
| 1432 |
+
| `calibration_score` | How well your confidence matches your actual accuracy |
|
| 1433 |
+
| `efficiency_bonus` | Reward for using fewer budget steps |
|
| 1434 |
+
| `contradiction_penalty` | Deducted if your hypothesis contradicts your own data |
|
| 1435 |
+
| `total_episode_reward` | Sum of all info gain rewards + final rubric score |
|
| 1436 |
+
| `ground_truth_revealed` | The actual hidden rules -- study this to improve! |
|
| 1437 |
+
|
| 1438 |
+
> **Design note: Why don't we reveal the exact noise sigma upfront?**
|
| 1439 |
+
>
|
| 1440 |
+
> The system message says "Noise level: low" but does NOT say "sigma=0.05".
|
| 1441 |
+
> In real science you have to estimate measurement uncertainty from repeated
|
| 1442 |
+
> measurements. This forces the agent to run a few repeat experiments to
|
| 1443 |
+
> gauge noise before trusting single data points. The qualitative label
|
| 1444 |
+
> (low/medium/high) sets expectations without handing out a free number.
|
| 1445 |
+
> The exact sigma IS shown per-experiment in the `noise_sigma` field --
|
| 1446 |
+
> that's fine because by then the agent has already spent a budget step.
|
| 1447 |
+
|
| 1448 |
+
### Error Handling
|
| 1449 |
+
|
| 1450 |
+
The environment returns error observations (not crashes) for bad actions:
|
| 1451 |
+
|
| 1452 |
+
| Situation | Response | Reward |
|
| 1453 |
+
|---|---|---|
|
| 1454 |
+
| Step without reset | `"Error: No active episode. Call reset() first."` | `-1.0`, `done=true` |
|
| 1455 |
+
| Step after episode ended | `"Error: Episode is already done."` | `0.0`, `done=true` |
|
| 1456 |
+
| Unknown variable name | `"Error: Unknown control variable 'X'."` | `-0.05`, budget deducted |
|
| 1457 |
+
| Unknown experiment type | `"Error: Unknown experiment type..."` | `-0.05` |
|
| 1458 |
+
| Unknown action type | `"Error: Unknown action_type..."` | `-0.05`, budget deducted |
|
| 1459 |
+
|
| 1460 |
+
The small negative reward (`-0.05`) for invalid actions teaches RL agents to
|
| 1461 |
+
produce valid requests without being so harsh that it dominates the reward signal.
|
| 1462 |
+
|
| 1463 |
+
### Stopping the Container
|
| 1464 |
+
|
| 1465 |
+
```bash
|
| 1466 |
+
# If running in foreground: Ctrl+C
|
| 1467 |
+
|
| 1468 |
+
# If running in background:
|
| 1469 |
+
docker stop hyp-lab
|
| 1470 |
+
docker rm hyp-lab
|
| 1471 |
+
```
|
| 1472 |
+
|
| 1473 |
+
### Troubleshooting
|
| 1474 |
+
|
| 1475 |
+
| Problem | Fix |
|
| 1476 |
+
|---|---|
|
| 1477 |
+
| `port is already allocated` | Another process uses port 8000. Use `-p 8001:8000` and hit `localhost:8001` instead |
|
| 1478 |
+
| `curl: (7) Failed to connect` | Container isn't running yet. Wait a few seconds for uvicorn to start |
|
| 1479 |
+
| `{"detail":"Not Found"}` | You hit the wrong endpoint. Use `/health`, `/reset`, `/step`, `/state` |
|
| 1480 |
+
| Container exits immediately | Check logs: `docker logs hyp-lab`. Usually a missing dependency |
|
| 1481 |
+
|
| 1482 |
+
### Deploying to HF Spaces
|
| 1483 |
+
|
| 1484 |
+
```bash
|
| 1485 |
+
openenv push --org your-org --token $HF_TOKEN
|
| 1486 |
+
```
|
| 1487 |
+
|
| 1488 |
+
The README.md has Hugging Face Spaces metadata in its YAML frontmatter:
|
| 1489 |
+
|
| 1490 |
+
```yaml
|
| 1491 |
+
---
|
| 1492 |
+
title: Scientific Hypothesis Lab
|
| 1493 |
+
emoji: 🔬
|
| 1494 |
+
sdk: docker
|
| 1495 |
+
app_port: 8000
|
| 1496 |
+
tags:
|
| 1497 |
+
- openenv
|
| 1498 |
+
---
|
| 1499 |
+
```
|
| 1500 |
+
|
| 1501 |
+
This tells HF Spaces to build the Docker image and expose port 8000.
|
| 1502 |
+
|
| 1503 |
+
---
|
| 1504 |
+
|
| 1505 |
+
## Part 14: Hands-On Exercises
|
| 1506 |
+
|
| 1507 |
+
Now it's your turn. These exercises go from easy to hard.
|
| 1508 |
+
|
| 1509 |
+
### Exercise 1: Explore a World (5 min)
|
| 1510 |
+
|
| 1511 |
+
```python
|
| 1512 |
+
from server.causal_world import generate_world
|
| 1513 |
+
|
| 1514 |
+
# Generate 3 different worlds and print their ground truth
|
| 1515 |
+
for seed in [1, 2, 3]:
|
| 1516 |
+
world = generate_world(n_variables=3, domain="system_gamma", seed=seed)
|
| 1517 |
+
print(f"\n=== Seed {seed} ===")
|
| 1518 |
+
print(f"Variables: {world.variables}")
|
| 1519 |
+
print(f"Interactions: {len(world.interactions)}")
|
| 1520 |
+
print(f"Confounder sigma: {world.confounder_sigma}")
|
| 1521 |
+
print(world.ground_truth_summary())
|
| 1522 |
+
```
|
| 1523 |
+
|
| 1524 |
+
Questions to answer:
|
| 1525 |
+
- How many rules does each world have? What types?
|
| 1526 |
+
- Do any worlds have interaction rules or confounders?
|
| 1527 |
+
- Are variable names abstract (no real-world physics terms)?
|
| 1528 |
+
|
| 1529 |
+
### Exercise 2: Play a Full Episode (10 min)
|
| 1530 |
+
|
| 1531 |
+
```python
|
| 1532 |
+
from models import ActionType, ExperimentType, HypLabAction
|
| 1533 |
+
from server.hypothesis_lab_environment import HypothesisLabEnvironment
|
| 1534 |
+
|
| 1535 |
+
env = HypothesisLabEnvironment()
|
| 1536 |
+
obs = env.reset(seed=100, noise_level="medium", domain="system_beta")
|
| 1537 |
+
print(obs.system_message)
|
| 1538 |
+
|
| 1539 |
+
# YOUR TURN: Run 3-4 experiments, then submit a hypothesis.
|
| 1540 |
+
# Try to get the highest accuracy score you can.
|
| 1541 |
+
# Hint: use CORRELATION to see the relationship shape,
|
| 1542 |
+
# then test at extreme values to distinguish linear from quadratic/saturating.
|
| 1543 |
+
```
|
| 1544 |
+
|
| 1545 |
+
### Exercise 3: Break the Rubric (10 min)
|
| 1546 |
+
|
| 1547 |
+
Try to get edge-case scores:
|
| 1548 |
+
- Get accuracy_score = 0.0 (submit empty hypothesis)
|
| 1549 |
+
- Get contradiction_penalty = -0.50 (claim "no causal relationship exists")
|
| 1550 |
+
- Get efficiency_bonus = 0.15 (submit early with high accuracy)
|
| 1551 |
+
- Get calibration_score = 0.20 (match your confidence to your accuracy perfectly)
|
| 1552 |
+
|
| 1553 |
+
### Exercise 4: Add a New Rule Type (20 min)
|
| 1554 |
+
|
| 1555 |
+
The environment already has 8 rule types, but you can add more! Try adding a **sinusoidal** rule:
|
| 1556 |
+
- Formula: `y = a * sin(k * x) + b`
|
| 1557 |
+
- Add it to `CausalRule.evaluate()`
|
| 1558 |
+
- Add it to `RULE_TYPES` and `_random_rule()` with appropriate weights
|
| 1559 |
+
- Add keywords to `_RULE_KEYWORDS` in `rubric.py`
|
| 1560 |
+
- Test it with a hand-crafted world
|
| 1561 |
+
|
| 1562 |
+
### Exercise 5: Add a New Variable Pool (10 min)
|
| 1563 |
+
|
| 1564 |
+
Add a new abstract variable pool to `ABSTRACT_VAR_POOLS` in `causal_world.py`:
|
| 1565 |
+
- Use creative abstract names (e.g., colour names: "Red", "Blue", "Green", "Amber", "Violet")
|
| 1566 |
+
- Make sure they carry no scientific meaning
|
| 1567 |
+
|
| 1568 |
+
### Exercise 6: Write a Smarter Baseline Agent (30 min)
|
| 1569 |
+
|
| 1570 |
+
Modify `baseline_inference.py` to implement a better strategy:
|
| 1571 |
+
1. First, run passive observations on all variables
|
| 1572 |
+
2. Then run interventions between each pair to find which are connected
|
| 1573 |
+
3. Use wide correlation sweeps (1 to 100) to check for curvature, saturation, or breakpoints
|
| 1574 |
+
4. Test at x=0.5 and x=50 to distinguish linear from exponential/logarithmic
|
| 1575 |
+
5. If the data suggests two parents, try holding one constant while varying the other
|
| 1576 |
+
6. Submit with well-calibrated confidence
|
| 1577 |
+
|
| 1578 |
+
---
|
| 1579 |
+
|
| 1580 |
+
## Part 15: Golden Rules for Building Environments
|
| 1581 |
+
|
| 1582 |
+
These are the principles that separate good environments from great ones.
|
| 1583 |
+
|
| 1584 |
+
### Rule 1: The Agent Should Never See the Answer
|
| 1585 |
+
|
| 1586 |
+
The hidden world, ground truth rules, and correct parameters must NEVER appear in observations or state before the agent submits. This is the most common mistake beginners make.
|
| 1587 |
+
|
| 1588 |
+
**Bad:**
|
| 1589 |
+
```python
|
| 1590 |
+
def reset(self):
|
| 1591 |
+
return Observation(hint=f"The slope is {self.world.rules[0].params['a']}")
|
| 1592 |
+
```
|
| 1593 |
+
|
| 1594 |
+
**Good:**
|
| 1595 |
+
```python
|
| 1596 |
+
def reset(self):
|
| 1597 |
+
return Observation(system_message="Run experiments to discover the hidden rules.")
|
| 1598 |
+
```
|
| 1599 |
+
|
| 1600 |
+
### Rule 2: Reward Shaping > Sparse Rewards
|
| 1601 |
+
|
| 1602 |
+
A reward function that only gives +1 at the end teaches nothing. The agent needs signal throughout the episode.
|
| 1603 |
+
|
| 1604 |
+
**Bad:**
|
| 1605 |
+
```python
|
| 1606 |
+
def step(self, action):
|
| 1607 |
+
if action.type == "submit":
|
| 1608 |
+
return Observation(reward=1.0 if correct else 0.0, done=True)
|
| 1609 |
+
return Observation(reward=0.0) # No signal during experiments!
|
| 1610 |
+
```
|
| 1611 |
+
|
| 1612 |
+
**Good:**
|
| 1613 |
+
```python
|
| 1614 |
+
def step(self, action):
|
| 1615 |
+
if action.type == "experiment":
|
| 1616 |
+
info_gain = self.tracker.record(action)
|
| 1617 |
+
return Observation(reward=info_gain) # Signal at every step!
|
| 1618 |
+
elif action.type == "submit":
|
| 1619 |
+
return Observation(reward=self.rubric.score(action))
|
| 1620 |
+
```
|
| 1621 |
+
|
| 1622 |
+
### Rule 3: Deterministic Seeds for Reproducibility
|
| 1623 |
+
|
| 1624 |
+
Every random element must be controlled by a seed. If two runs with the same seed produce different results, your graders are broken.
|
| 1625 |
+
|
| 1626 |
+
```python
|
| 1627 |
+
def generate_world(seed=42):
|
| 1628 |
+
py_rng = random.Random(seed) # Controls structure
|
| 1629 |
+
np_rng = np.random.default_rng(seed) # Controls noise
|
| 1630 |
+
```
|
| 1631 |
+
|
| 1632 |
+
### Rule 4: Observations Should Be LLM-Friendly
|
| 1633 |
+
|
| 1634 |
+
If your agent is an LLM, the observation needs a human-readable text field. Don't just return a dict of numbers.
|
| 1635 |
+
|
| 1636 |
+
**Bad:**
|
| 1637 |
+
```python
|
| 1638 |
+
return Observation(result={"x": 5.0, "y": 13.04, "sigma": 0.05})
|
| 1639 |
+
```
|
| 1640 |
+
|
| 1641 |
+
**Good:**
|
| 1642 |
+
```python
|
| 1643 |
+
return Observation(
|
| 1644 |
+
system_message="[Step 1] Set Alpha=5.0, observed Beta=13.04 (sigma=0.05)",
|
| 1645 |
+
result_value=13.04,
|
| 1646 |
+
noise_sigma=0.05,
|
| 1647 |
+
)
|
| 1648 |
+
```
|
| 1649 |
+
|
| 1650 |
+
### Rule 5: Validate All Agent Input
|
| 1651 |
+
|
| 1652 |
+
Never trust the agent. It will send garbage, typos, and adversarial inputs.
|
| 1653 |
+
|
| 1654 |
+
```python
|
| 1655 |
+
if cause not in world.variables:
|
| 1656 |
+
return self._error_obs(f"Unknown variable '{cause}'. Available: {world.variables}")
|
| 1657 |
+
```
|
| 1658 |
+
|
| 1659 |
+
### Rule 6: Clean Episode Boundaries
|
| 1660 |
+
|
| 1661 |
+
`reset()` must produce a completely clean state. No leftover data from previous episodes.
|
| 1662 |
+
|
| 1663 |
+
```python
|
| 1664 |
+
def reset(self):
|
| 1665 |
+
self._world = generate_world(...) # Fresh world
|
| 1666 |
+
self._tracker = InfoGainTracker() # Fresh tracker
|
| 1667 |
+
self._history = [] # Fresh history
|
| 1668 |
+
self._done = False # Episode is active
|
| 1669 |
+
```
|
| 1670 |
+
|
| 1671 |
+
### Rule 7: Budget/Step Limits Prevent Infinite Episodes
|
| 1672 |
+
|
| 1673 |
+
Always have a mechanism to end the episode. Either a budget that runs out, or a maximum step count.
|
| 1674 |
+
|
| 1675 |
+
### Rule 8: The Hard Task Must Be Actually Hard
|
| 1676 |
+
|
| 1677 |
+
If your hard task is easy for GPT-4, the judges will notice. Design it so that even frontier models score 0.2-0.4 on the hard task. Our hard task uses 4 variables, sigma=0.50 noise, hidden confounders, interaction rules, and only 8 experiment budget.
|
| 1678 |
+
|
| 1679 |
+
### Rule 8.5: Don't Let LLMs Cheat with Prior Knowledge
|
| 1680 |
+
|
| 1681 |
+
If your environment uses real-world variable names (Temperature, Pressure, Price, Demand), LLM agents will use pretrained knowledge instead of reasoning from data. Use abstract names (Alpha, Beta, V1, V2) to force genuine discovery. Similarly, don't use only 3 rule types -- the agent will memorize the template set. Use enough variety that template-matching fails.
|
| 1682 |
+
|
| 1683 |
+
### Rule 9: Graders Must Be Deterministic
|
| 1684 |
+
|
| 1685 |
+
Given the same `episode_result` dict, a grader must always return the same score. No randomness, no external API calls, no time-dependent logic.
|
| 1686 |
+
|
| 1687 |
+
### Rule 10: State Metadata Only
|
| 1688 |
+
|
| 1689 |
+
The `state` property returns metadata, not secrets. It's for debugging, logging, and agent introspection -- never for leaking the answer.
|
| 1690 |
+
|
| 1691 |
+
---
|
| 1692 |
+
|
| 1693 |
+
## Part 16: How to Build Your Own From Scratch
|
| 1694 |
+
|
| 1695 |
+
Here's the step-by-step recipe for creating a new OpenEnv environment.
|
| 1696 |
+
|
| 1697 |
+
### Step 1: Choose Your Domain
|
| 1698 |
+
|
| 1699 |
+
Pick a real-world task humans actually do:
|
| 1700 |
+
- Email triage
|
| 1701 |
+
- Code review
|
| 1702 |
+
- Data cleaning
|
| 1703 |
+
- Scheduling
|
| 1704 |
+
- Customer support
|
| 1705 |
+
- Medical diagnosis
|
| 1706 |
+
- Financial analysis
|
| 1707 |
+
|
| 1708 |
+
### Step 2: Define the Action Space
|
| 1709 |
+
|
| 1710 |
+
What can the agent do? Write it out in plain English first:
|
| 1711 |
+
|
| 1712 |
+
```
|
| 1713 |
+
The agent can:
|
| 1714 |
+
1. Read an email subject and preview
|
| 1715 |
+
2. Assign a priority (high/medium/low)
|
| 1716 |
+
3. Assign a label (bug/feature/question/spam)
|
| 1717 |
+
4. Flag for human review
|
| 1718 |
+
```
|
| 1719 |
+
|
| 1720 |
+
Then convert to a Pydantic model:
|
| 1721 |
+
|
| 1722 |
+
```python
|
| 1723 |
+
class EmailAction(Action):
|
| 1724 |
+
action_type: str # "classify" or "flag"
|
| 1725 |
+
priority: Optional[str] = None
|
| 1726 |
+
label: Optional[str] = None
|
| 1727 |
+
flag_reason: Optional[str] = None
|
| 1728 |
+
```
|
| 1729 |
+
|
| 1730 |
+
### Step 3: Define the Observation Space
|
| 1731 |
+
|
| 1732 |
+
What does the agent see after each action?
|
| 1733 |
+
|
| 1734 |
+
```python
|
| 1735 |
+
class EmailObservation(Observation):
|
| 1736 |
+
system_message: str
|
| 1737 |
+
email_subject: str
|
| 1738 |
+
email_preview: str
|
| 1739 |
+
emails_remaining: int
|
| 1740 |
+
# ... (inherits done, reward from Observation)
|
| 1741 |
+
```
|
| 1742 |
+
|
| 1743 |
+
### Step 4: Build the Hidden World
|
| 1744 |
+
|
| 1745 |
+
What's the ground truth the agent is trying to discover/solve? This is your "puzzle generator."
|
| 1746 |
+
|
| 1747 |
+
### Step 5: Build the Reward Function
|
| 1748 |
+
|
| 1749 |
+
Design rewards that teach the right behavior:
|
| 1750 |
+
- Correct classification: +1.0
|
| 1751 |
+
- Partially correct: +0.5
|
| 1752 |
+
- Wrong but not harmful: -0.1
|
| 1753 |
+
- Flagging spam as high priority: -0.5
|
| 1754 |
+
|
| 1755 |
+
### Step 6: Write the Environment Class
|
| 1756 |
+
|
| 1757 |
+
```python
|
| 1758 |
+
class EmailTriageEnvironment(Environment):
|
| 1759 |
+
def reset(self, **kwargs):
|
| 1760 |
+
# Generate a batch of emails
|
| 1761 |
+
# Return the first email as an observation
|
| 1762 |
+
|
| 1763 |
+
def step(self, action):
|
| 1764 |
+
# Grade the agent's classification
|
| 1765 |
+
# Move to next email or end episode
|
| 1766 |
+
|
| 1767 |
+
@property
|
| 1768 |
+
def state(self):
|
| 1769 |
+
# Return progress metadata
|
| 1770 |
+
```
|
| 1771 |
+
|
| 1772 |
+
### Step 7: Wire Up the Server
|
| 1773 |
+
|
| 1774 |
+
```python
|
| 1775 |
+
app = create_app(
|
| 1776 |
+
EmailTriageEnvironment,
|
| 1777 |
+
EmailAction,
|
| 1778 |
+
EmailObservation,
|
| 1779 |
+
env_name="email_triage",
|
| 1780 |
+
)
|
| 1781 |
+
```
|
| 1782 |
+
|
| 1783 |
+
### Step 8: Define 3 Tasks
|
| 1784 |
+
|
| 1785 |
+
```python
|
| 1786 |
+
TASK_EASY = {"id": "easy", "reset_kwargs": {"n_emails": 5, "spam_ratio": 0.5}}
|
| 1787 |
+
TASK_MEDIUM = {"id": "medium", "reset_kwargs": {"n_emails": 10, "spam_ratio": 0.2}}
|
| 1788 |
+
TASK_HARD = {"id": "hard", "reset_kwargs": {"n_emails": 20, "spam_ratio": 0.05}}
|
| 1789 |
+
```
|
| 1790 |
+
|
| 1791 |
+
### Step 9: Write the Baseline
|
| 1792 |
+
|
| 1793 |
+
Use the OpenAI API to run a simple agent and produce baseline scores.
|
| 1794 |
+
|
| 1795 |
+
### Step 10: Write Tests
|
| 1796 |
+
|
| 1797 |
+
Minimum tests:
|
| 1798 |
+
- reset() produces valid observation
|
| 1799 |
+
- step() with valid action works
|
| 1800 |
+
- step() with invalid action returns error
|
| 1801 |
+
- Episode ends when expected
|
| 1802 |
+
- State doesn't leak secrets
|
| 1803 |
+
- Graders return [0, 1]
|
| 1804 |
+
- Seeds produce deterministic results
|
| 1805 |
+
|
| 1806 |
+
### Step 11: Write the Dockerfile
|
| 1807 |
+
|
| 1808 |
+
Copy our Dockerfile template. Change the CMD to point to your server module.
|
| 1809 |
+
|
| 1810 |
+
### Step 12: Write openenv.yaml
|
| 1811 |
+
|
| 1812 |
+
```yaml
|
| 1813 |
+
spec_version: 1
|
| 1814 |
+
name: your_env_name
|
| 1815 |
+
type: space
|
| 1816 |
+
runtime: fastapi
|
| 1817 |
+
app: server.app:app
|
| 1818 |
+
port: 8000
|
| 1819 |
+
```
|
| 1820 |
+
|
| 1821 |
+
### Step 13: Write the README
|
| 1822 |
+
|
| 1823 |
+
Include HF Spaces frontmatter, environment description, action/observation docs, task descriptions, and baseline scores.
|
| 1824 |
+
|
| 1825 |
+
---
|
| 1826 |
+
|
| 1827 |
+
## Congratulations
|
| 1828 |
+
|
| 1829 |
+
You've read through the entire Scientific Hypothesis Lab codebase and understand:
|
| 1830 |
+
|
| 1831 |
+
- **What RL environments are** and how agents interact with them
|
| 1832 |
+
- **The OpenEnv contract**: reset/step/state, Action/Observation/State, openenv.yaml
|
| 1833 |
+
- **How hidden worlds work**: causal graphs with 8+ rule types, interaction rules, confounders, abstract variable names
|
| 1834 |
+
- **Why abstract variable names matter**: prevents LLMs from using pretrained knowledge as a shortcut
|
| 1835 |
+
- **How reward functions are designed**: info gain, accuracy (across all rule types + interactions), calibration, efficiency, contradiction
|
| 1836 |
+
- **How the server works**: create_app() wraps everything in HTTP endpoints
|
| 1837 |
+
- **How clients connect**: typed methods over WebSocket
|
| 1838 |
+
- **How tasks and graders work**: difficulty progression, deterministic scoring [0, 1]
|
| 1839 |
+
- **How baseline agents work**: LLM + system prompt + action parsing
|
| 1840 |
+
- **How to test**: 39 tests covering every component including all rule types
|
| 1841 |
+
- **How to deploy**: Docker + HF Spaces
|
| 1842 |
+
- **The golden rules** for building great environments (including anti-cheating via abstract naming)
|
| 1843 |
+
- **How to build your own** from scratch in 13 steps
|
| 1844 |
+
|
| 1845 |
+
You are now qualified to build, debug, explain, and teach RL environments. Go build something amazing.
|
__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Scientific Hypothesis Lab -- OpenEnv Environment for causal discovery."""
|
__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (246 Bytes). View file
|
|
|
__pycache__/baseline_inference.cpython-311.pyc
ADDED
|
Binary file (9.98 kB). View file
|
|
|
__pycache__/client.cpython-311.pyc
ADDED
|
Binary file (6.06 kB). View file
|
|
|
__pycache__/conftest.cpython-311-pytest-9.0.2.pyc
ADDED
|
Binary file (624 Bytes). View file
|
|
|
__pycache__/models.cpython-311.pyc
ADDED
|
Binary file (7.9 kB). View file
|
|
|
baseline_inference.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
baseline_inference.py -- Baseline agent using the OpenAI API.
|
| 4 |
+
|
| 5 |
+
Reads OPENAI_API_KEY from environment variables.
|
| 6 |
+
Runs all 3 tasks (easy, medium, hard) and prints reproducible scores.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
# Start the server first:
|
| 10 |
+
uvicorn server.app:app --port 8000
|
| 11 |
+
|
| 12 |
+
# Then run the baseline:
|
| 13 |
+
export OPENAI_API_KEY=sk-...
|
| 14 |
+
python baseline_inference.py
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import re
|
| 22 |
+
import sys
|
| 23 |
+
from typing import Any, Optional
|
| 24 |
+
|
| 25 |
+
from openai import OpenAI
|
| 26 |
+
|
| 27 |
+
from server.hypothesis_lab_environment import HypothesisLabEnvironment
|
| 28 |
+
from models import ActionType, ExperimentType, HypLabAction, NoiseLevelTag
|
| 29 |
+
from tasks import ALL_TASKS
|
| 30 |
+
from tasks.task_easy import grade_easy
|
| 31 |
+
from tasks.task_medium import grade_medium
|
| 32 |
+
from tasks.task_hard import grade_hard
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
SYSTEM_PROMPT_RL = """You are a scientific AI assistant. You must discover hidden causal rules between variables through experimentation.
|
| 36 |
+
|
| 37 |
+
You can take these actions (respond with valid JSON):
|
| 38 |
+
|
| 39 |
+
EXPERIMENT -- probe the system:
|
| 40 |
+
{"action_type": "experiment", "experiment_type": "<type>", "control_variable": "<var>", "target_variable": "<var>", ...}
|
| 41 |
+
|
| 42 |
+
Experiment types:
|
| 43 |
+
"intervention" -- set control_variable to control_value, observe target
|
| 44 |
+
"correlation" -- sweep control_variable over control_range [min, max, n_points], observe target
|
| 45 |
+
"counterfactual" -- ask what happens if control_variable changes by control_value (delta)
|
| 46 |
+
"passive" -- observe target_variable in its resting state
|
| 47 |
+
|
| 48 |
+
SUBMIT -- end the episode with your hypothesis:
|
| 49 |
+
{"action_type": "submit", "hypothesis_text": "<your hypothesis>", "hypothesis_equations": ["<equation>"], "confidence": <0.0-1.0>}
|
| 50 |
+
|
| 51 |
+
Discover the rules. Submit when ready."""
|
| 52 |
+
|
| 53 |
+
SYSTEM_PROMPT_BASELINE = SYSTEM_PROMPT_RL + """
|
| 54 |
+
|
| 55 |
+
Strategy tips (for baseline evaluation only -- remove for RL training):
|
| 56 |
+
- Run interventions first to discover which variables are causally connected
|
| 57 |
+
- Vary the control variable widely (e.g. 1, 5, 10) to detect nonlinearity
|
| 58 |
+
- Don't repeat the same experiment -- redundant experiments are penalised
|
| 59 |
+
- Submit early with confidence if you have strong evidence (efficiency bonus)
|
| 60 |
+
- Include numerical values (slopes, thresholds) in your hypothesis for precision bonus
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
GRADERS = {
|
| 65 |
+
"easy": grade_easy,
|
| 66 |
+
"medium": grade_medium,
|
| 67 |
+
"hard": grade_hard,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
MAX_TURNS = 8
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def parse_action(text: str, obs_vars: list[str], turn: int) -> Optional[HypLabAction]:
|
| 74 |
+
"""Parse a HypLabAction from LLM-generated text."""
|
| 75 |
+
if turn >= MAX_TURNS - 1:
|
| 76 |
+
return HypLabAction(
|
| 77 |
+
action_type=ActionType.SUBMIT,
|
| 78 |
+
hypothesis_text=text[:1000],
|
| 79 |
+
confidence=0.5,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
| 83 |
+
raw = json_match.group(1) if json_match else text.strip()
|
| 84 |
+
|
| 85 |
+
brace_match = re.search(r"\{[^{}]*\}", raw, re.DOTALL)
|
| 86 |
+
if brace_match:
|
| 87 |
+
raw = brace_match.group(0)
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
data = json.loads(raw)
|
| 91 |
+
return HypLabAction(**data)
|
| 92 |
+
except Exception:
|
| 93 |
+
pass
|
| 94 |
+
|
| 95 |
+
text_l = text.lower()
|
| 96 |
+
if any(w in text_l for w in ["submit", "hypothesis:", "my hypothesis", "i conclude"]):
|
| 97 |
+
hyp_match = re.search(
|
| 98 |
+
r"(?:hypothesis|conclude|rule)[:\s]+(.{10,500})", text, re.IGNORECASE
|
| 99 |
+
)
|
| 100 |
+
hyp_text = hyp_match.group(1) if hyp_match else text[:500]
|
| 101 |
+
return HypLabAction(
|
| 102 |
+
action_type=ActionType.SUBMIT,
|
| 103 |
+
hypothesis_text=hyp_text.strip(),
|
| 104 |
+
confidence=0.6,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def run_episode(
|
| 111 |
+
client: OpenAI,
|
| 112 |
+
model: str,
|
| 113 |
+
task: dict[str, Any],
|
| 114 |
+
use_hints: bool = True,
|
| 115 |
+
) -> dict[str, Any]:
|
| 116 |
+
"""Run a single episode and return the grading result dict."""
|
| 117 |
+
env = HypothesisLabEnvironment()
|
| 118 |
+
reset_kwargs = dict(task["reset_kwargs"])
|
| 119 |
+
seed = reset_kwargs.pop("seed", None)
|
| 120 |
+
|
| 121 |
+
obs = env.reset(seed=seed, **reset_kwargs)
|
| 122 |
+
|
| 123 |
+
prompt = SYSTEM_PROMPT_BASELINE if use_hints else SYSTEM_PROMPT_RL
|
| 124 |
+
messages = [
|
| 125 |
+
{"role": "system", "content": prompt},
|
| 126 |
+
{"role": "user", "content": obs.system_message},
|
| 127 |
+
]
|
| 128 |
+
|
| 129 |
+
last_obs = obs
|
| 130 |
+
for turn in range(MAX_TURNS):
|
| 131 |
+
if last_obs.done:
|
| 132 |
+
break
|
| 133 |
+
|
| 134 |
+
response = client.chat.completions.create(
|
| 135 |
+
model=model,
|
| 136 |
+
messages=messages,
|
| 137 |
+
temperature=0.3,
|
| 138 |
+
max_tokens=512,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
assistant_text = response.choices[0].message.content or ""
|
| 142 |
+
messages.append({"role": "assistant", "content": assistant_text})
|
| 143 |
+
|
| 144 |
+
action = parse_action(assistant_text, last_obs.available_variables, turn)
|
| 145 |
+
|
| 146 |
+
if action is None:
|
| 147 |
+
messages.append({
|
| 148 |
+
"role": "user",
|
| 149 |
+
"content": "Invalid action format. Please respond with a valid JSON action.",
|
| 150 |
+
})
|
| 151 |
+
continue
|
| 152 |
+
|
| 153 |
+
last_obs = env.step(action)
|
| 154 |
+
messages.append({"role": "user", "content": last_obs.system_message})
|
| 155 |
+
|
| 156 |
+
if not last_obs.done:
|
| 157 |
+
submit = HypLabAction(
|
| 158 |
+
action_type=ActionType.SUBMIT,
|
| 159 |
+
hypothesis_text="Unable to determine -- insufficient experiments.",
|
| 160 |
+
confidence=0.1,
|
| 161 |
+
)
|
| 162 |
+
last_obs = env.step(submit)
|
| 163 |
+
|
| 164 |
+
return {
|
| 165 |
+
"accuracy_score": last_obs.accuracy_score or 0.0,
|
| 166 |
+
"precision_bonus": last_obs.precision_bonus or 0.0,
|
| 167 |
+
"calibration_score": last_obs.calibration_score or 0.0,
|
| 168 |
+
"efficiency_bonus": last_obs.efficiency_bonus or 0.0,
|
| 169 |
+
"contradiction_penalty": last_obs.contradiction_penalty or 0.0,
|
| 170 |
+
"total_episode_reward": last_obs.total_episode_reward or 0.0,
|
| 171 |
+
"ground_truth": last_obs.ground_truth_revealed or "",
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def run_all_tasks() -> dict[str, Any]:
|
| 176 |
+
"""Run baseline agent on all tasks and return scores.
|
| 177 |
+
|
| 178 |
+
Callable from both the CLI and the /baseline endpoint.
|
| 179 |
+
Requires OPENAI_API_KEY in environment.
|
| 180 |
+
"""
|
| 181 |
+
api_key = os.environ.get("OPENAI_API_KEY")
|
| 182 |
+
if not api_key:
|
| 183 |
+
raise RuntimeError("OPENAI_API_KEY environment variable not set.")
|
| 184 |
+
|
| 185 |
+
model = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
|
| 186 |
+
client = OpenAI(api_key=api_key)
|
| 187 |
+
|
| 188 |
+
results: dict[str, Any] = {}
|
| 189 |
+
for task in ALL_TASKS:
|
| 190 |
+
task_id = task["id"]
|
| 191 |
+
episode_result = run_episode(client, model, task)
|
| 192 |
+
grader = GRADERS[task_id]
|
| 193 |
+
score = grader(episode_result)
|
| 194 |
+
results[task_id] = {
|
| 195 |
+
"score": score,
|
| 196 |
+
"episode_result": episode_result,
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
avg = sum(r["score"] for r in results.values()) / max(len(results), 1)
|
| 200 |
+
results["average_score"] = round(avg, 4)
|
| 201 |
+
return results
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def main():
|
| 205 |
+
api_key = os.environ.get("OPENAI_API_KEY")
|
| 206 |
+
if not api_key:
|
| 207 |
+
print("ERROR: Set OPENAI_API_KEY environment variable.")
|
| 208 |
+
sys.exit(1)
|
| 209 |
+
|
| 210 |
+
model = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
|
| 211 |
+
client = OpenAI(api_key=api_key)
|
| 212 |
+
|
| 213 |
+
print("=" * 60)
|
| 214 |
+
print(" Scientific Hypothesis Lab -- Baseline Inference")
|
| 215 |
+
print(f" Model: {model}")
|
| 216 |
+
print("=" * 60)
|
| 217 |
+
print()
|
| 218 |
+
|
| 219 |
+
results = {}
|
| 220 |
+
for task in ALL_TASKS:
|
| 221 |
+
task_id = task["id"]
|
| 222 |
+
print(f"--- Task: {task['name']} ---")
|
| 223 |
+
print(f" {task['description']}")
|
| 224 |
+
|
| 225 |
+
episode_result = run_episode(client, model, task)
|
| 226 |
+
|
| 227 |
+
grader = GRADERS[task_id]
|
| 228 |
+
score = grader(episode_result)
|
| 229 |
+
|
| 230 |
+
results[task_id] = {
|
| 231 |
+
"score": score,
|
| 232 |
+
"episode_result": episode_result,
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
print(f" Total episode reward: {episode_result['total_episode_reward']:+.4f}")
|
| 236 |
+
print(f" Graded score: {score:.4f}")
|
| 237 |
+
print()
|
| 238 |
+
|
| 239 |
+
print("=" * 60)
|
| 240 |
+
print(" SUMMARY")
|
| 241 |
+
print("=" * 60)
|
| 242 |
+
for task_id, r in results.items():
|
| 243 |
+
print(f" {task_id:8s}: {r['score']:.4f}")
|
| 244 |
+
|
| 245 |
+
avg = sum(r["score"] for r in results.values()) / len(results)
|
| 246 |
+
print(f" {'average':8s}: {avg:.4f}")
|
| 247 |
+
print()
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
if __name__ == "__main__":
|
| 251 |
+
main()
|
client.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
client.py -- Typed Python client for HypothesisLab.
|
| 3 |
+
|
| 4 |
+
Built on openenv.core.env_client.EnvClient (WebSocket-based, persistent).
|
| 5 |
+
This is what the RL trainer and baseline inference script import.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any, Dict, Optional
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
from openenv.core.env_client import EnvClient
|
| 14 |
+
from openenv.core.client_types import StepResult
|
| 15 |
+
except ImportError:
|
| 16 |
+
raise ImportError(
|
| 17 |
+
"openenv-core is required. Install with: pip install openenv-core"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
from .models import (
|
| 22 |
+
ActionType,
|
| 23 |
+
ExperimentType,
|
| 24 |
+
HypLabAction,
|
| 25 |
+
HypLabObservation,
|
| 26 |
+
HypLabState,
|
| 27 |
+
NoiseLevelTag,
|
| 28 |
+
)
|
| 29 |
+
except ImportError:
|
| 30 |
+
from models import (
|
| 31 |
+
ActionType,
|
| 32 |
+
ExperimentType,
|
| 33 |
+
HypLabAction,
|
| 34 |
+
HypLabObservation,
|
| 35 |
+
HypLabState,
|
| 36 |
+
NoiseLevelTag,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class HypothesisLabEnv(EnvClient[HypLabAction, HypLabObservation, HypLabState]):
|
| 41 |
+
"""
|
| 42 |
+
Typed async client for the Scientific Hypothesis Lab environment.
|
| 43 |
+
|
| 44 |
+
Usage (async):
|
| 45 |
+
async with HypothesisLabEnv(base_url="http://localhost:8000") as env:
|
| 46 |
+
result = await env.reset(noise_level="low", domain="physics")
|
| 47 |
+
obs = result.observation
|
| 48 |
+
...
|
| 49 |
+
|
| 50 |
+
Usage (sync):
|
| 51 |
+
env = HypothesisLabEnv(base_url="http://localhost:8000").sync()
|
| 52 |
+
with env:
|
| 53 |
+
result = env.reset(noise_level="low")
|
| 54 |
+
...
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
def _step_payload(self, action: HypLabAction) -> Dict[str, Any]:
|
| 58 |
+
return action.model_dump(exclude_none=True)
|
| 59 |
+
|
| 60 |
+
def _parse_result(self, payload: Dict[str, Any]) -> StepResult[HypLabObservation]:
|
| 61 |
+
obs_data = payload.get("observation", payload)
|
| 62 |
+
obs = HypLabObservation(**obs_data)
|
| 63 |
+
return StepResult(
|
| 64 |
+
observation=obs,
|
| 65 |
+
reward=payload.get("reward", obs.reward or 0.0),
|
| 66 |
+
done=payload.get("done", obs.done),
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
def _parse_state(self, payload: Dict[str, Any]) -> HypLabState:
|
| 70 |
+
return HypLabState(**payload)
|
| 71 |
+
|
| 72 |
+
async def run_intervention(
|
| 73 |
+
self,
|
| 74 |
+
control_variable: str,
|
| 75 |
+
control_value: float,
|
| 76 |
+
target_variable: str,
|
| 77 |
+
) -> StepResult[HypLabObservation]:
|
| 78 |
+
action = HypLabAction(
|
| 79 |
+
action_type=ActionType.EXPERIMENT,
|
| 80 |
+
experiment_type=ExperimentType.INTERVENTION,
|
| 81 |
+
control_variable=control_variable,
|
| 82 |
+
target_variable=target_variable,
|
| 83 |
+
control_value=control_value,
|
| 84 |
+
)
|
| 85 |
+
return await self.step(action)
|
| 86 |
+
|
| 87 |
+
async def run_correlation(
|
| 88 |
+
self,
|
| 89 |
+
control_variable: str,
|
| 90 |
+
control_range: list[float],
|
| 91 |
+
target_variable: str,
|
| 92 |
+
) -> StepResult[HypLabObservation]:
|
| 93 |
+
action = HypLabAction(
|
| 94 |
+
action_type=ActionType.EXPERIMENT,
|
| 95 |
+
experiment_type=ExperimentType.CORRELATION,
|
| 96 |
+
control_variable=control_variable,
|
| 97 |
+
control_range=control_range,
|
| 98 |
+
target_variable=target_variable,
|
| 99 |
+
)
|
| 100 |
+
return await self.step(action)
|
| 101 |
+
|
| 102 |
+
async def run_counterfactual(
|
| 103 |
+
self,
|
| 104 |
+
control_variable: str,
|
| 105 |
+
delta: float,
|
| 106 |
+
target_variable: str,
|
| 107 |
+
) -> StepResult[HypLabObservation]:
|
| 108 |
+
action = HypLabAction(
|
| 109 |
+
action_type=ActionType.EXPERIMENT,
|
| 110 |
+
experiment_type=ExperimentType.COUNTERFACTUAL,
|
| 111 |
+
control_variable=control_variable,
|
| 112 |
+
control_value=delta,
|
| 113 |
+
target_variable=target_variable,
|
| 114 |
+
)
|
| 115 |
+
return await self.step(action)
|
| 116 |
+
|
| 117 |
+
async def run_passive(
|
| 118 |
+
self, target_variable: str
|
| 119 |
+
) -> StepResult[HypLabObservation]:
|
| 120 |
+
action = HypLabAction(
|
| 121 |
+
action_type=ActionType.EXPERIMENT,
|
| 122 |
+
experiment_type=ExperimentType.PASSIVE,
|
| 123 |
+
target_variable=target_variable,
|
| 124 |
+
control_variable=target_variable,
|
| 125 |
+
)
|
| 126 |
+
return await self.step(action)
|
| 127 |
+
|
| 128 |
+
async def submit_hypothesis(
|
| 129 |
+
self,
|
| 130 |
+
hypothesis_text: str,
|
| 131 |
+
hypothesis_equations: Optional[list[str]] = None,
|
| 132 |
+
confidence: float = 0.75,
|
| 133 |
+
) -> StepResult[HypLabObservation]:
|
| 134 |
+
action = HypLabAction(
|
| 135 |
+
action_type=ActionType.SUBMIT,
|
| 136 |
+
hypothesis_text=hypothesis_text,
|
| 137 |
+
hypothesis_equations=hypothesis_equations,
|
| 138 |
+
confidence=max(0.0, min(1.0, confidence)),
|
| 139 |
+
)
|
| 140 |
+
return await self.step(action)
|
conftest.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pytest conftest -- ensure project root is on sys.path."""
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
hypothesis_lab.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: hypothesis-lab
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Scientific Hypothesis Lab -- OpenEnv RL environment for causal discovery under noise
|
| 5 |
+
License: MIT
|
| 6 |
+
Requires-Python: >=3.10
|
| 7 |
+
Description-Content-Type: text/markdown
|
| 8 |
+
Requires-Dist: openenv-core[core]>=0.2.1
|
| 9 |
+
Requires-Dist: fastapi>=0.111.0
|
| 10 |
+
Requires-Dist: uvicorn[standard]>=0.29.0
|
| 11 |
+
Requires-Dist: pydantic>=2.7.0
|
| 12 |
+
Requires-Dist: numpy>=1.26.0
|
| 13 |
+
Requires-Dist: networkx>=3.3
|
| 14 |
+
Provides-Extra: baseline
|
| 15 |
+
Requires-Dist: openai>=1.30.0; extra == "baseline"
|
| 16 |
+
Provides-Extra: dev
|
| 17 |
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
| 18 |
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
| 19 |
+
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
| 20 |
+
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
title: Scientific Hypothesis Lab
|
| 24 |
+
emoji: 🔬
|
| 25 |
+
colorFrom: blue
|
| 26 |
+
colorTo: green
|
| 27 |
+
sdk: docker
|
| 28 |
+
pinned: false
|
| 29 |
+
app_port: 8000
|
| 30 |
+
base_path: /web
|
| 31 |
+
tags:
|
| 32 |
+
- openenv
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
# Scientific Hypothesis Lab -- OpenEnv Environment
|
| 36 |
+
|
| 37 |
+
An RL environment where agents discover hidden causal rules through systematic
|
| 38 |
+
experimentation. Built for the [OpenEnv Hub](https://huggingface.co/openenv).
|
| 39 |
+
|
| 40 |
+
## What it does
|
| 41 |
+
|
| 42 |
+
Each episode, the agent is presented with a set of **abstract** variables
|
| 43 |
+
(e.g. Alpha, Beta, Gamma or V1, V2, V3) from a randomised causal world.
|
| 44 |
+
Variable names are deliberately opaque so agents cannot leverage pretrained
|
| 45 |
+
real-world knowledge -- they must reason purely from experimental evidence.
|
| 46 |
+
|
| 47 |
+
The hidden rules span **8 single-parent function types** (linear, threshold,
|
| 48 |
+
inverse, quadratic, exponential, logarithmic, saturating, piecewise-linear),
|
| 49 |
+
**multi-parent interaction rules** (additive, multiplicative, min, max), and
|
| 50 |
+
optional **hidden confounders** that inject unexplainable correlated noise.
|
| 51 |
+
|
| 52 |
+
The agent must:
|
| 53 |
+
|
| 54 |
+
1. **Design experiments** -- probe variable relationships using interventions,
|
| 55 |
+
correlations, counterfactuals, or passive observations
|
| 56 |
+
2. **Update beliefs** from noisy experimental results
|
| 57 |
+
3. **Submit a hypothesis** -- a structured description of the discovered causal rules
|
| 58 |
+
|
| 59 |
+
The environment rewards informative experiments, precise hypotheses, calibrated
|
| 60 |
+
confidence, and efficient budget use.
|
| 61 |
+
|
| 62 |
+
## Quick Start
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
# Install dependencies
|
| 66 |
+
pip install -e .
|
| 67 |
+
|
| 68 |
+
# Run the server locally
|
| 69 |
+
uvicorn server.app:app --port 8000
|
| 70 |
+
|
| 71 |
+
# In another terminal, run the baseline agent
|
| 72 |
+
export OPENAI_API_KEY=sk-...
|
| 73 |
+
python baseline_inference.py
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### Using the Client
|
| 77 |
+
|
| 78 |
+
```python
|
| 79 |
+
from hypothesis_lab import HypothesisLabEnv, HypLabAction, ActionType
|
| 80 |
+
|
| 81 |
+
# Async usage
|
| 82 |
+
async with HypothesisLabEnv(base_url="http://localhost:8000") as env:
|
| 83 |
+
result = await env.reset(noise_level="low", domain="system_alpha")
|
| 84 |
+
obs = result.observation
|
| 85 |
+
|
| 86 |
+
# Run an intervention
|
| 87 |
+
result = await env.run_intervention(
|
| 88 |
+
control_variable=obs.available_variables[0],
|
| 89 |
+
control_value=5.0,
|
| 90 |
+
target_variable=obs.available_variables[1],
|
| 91 |
+
)
|
| 92 |
+
print(result.observation.system_message)
|
| 93 |
+
|
| 94 |
+
# Submit hypothesis
|
| 95 |
+
result = await env.submit_hypothesis(
|
| 96 |
+
hypothesis_text="Beta = 2.1 * Alpha + 3.0",
|
| 97 |
+
confidence=0.85,
|
| 98 |
+
)
|
| 99 |
+
print(f"Score: {result.observation.total_episode_reward}")
|
| 100 |
+
|
| 101 |
+
# Sync usage
|
| 102 |
+
env = HypothesisLabEnv(base_url="http://localhost:8000").sync()
|
| 103 |
+
with env:
|
| 104 |
+
result = env.reset(noise_level="low")
|
| 105 |
+
...
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
## File Structure
|
| 109 |
+
|
| 110 |
+
```
|
| 111 |
+
hypothesis_lab/
|
| 112 |
+
├── openenv.yaml # OpenEnv manifest
|
| 113 |
+
├── pyproject.toml # Project metadata and dependencies
|
| 114 |
+
├── requirements.txt # Pip fallback dependencies
|
| 115 |
+
├── README.md # This file
|
| 116 |
+
├── models.py # Pydantic Action / Observation / State models
|
| 117 |
+
├── client.py # Typed EnvClient for agents and trainers
|
| 118 |
+
├── __init__.py # Module exports
|
| 119 |
+
├── baseline_inference.py # Baseline agent using OpenAI API
|
| 120 |
+
├── Dockerfile # For HF Spaces deployment
|
| 121 |
+
├── server/
|
| 122 |
+
│ ├── __init__.py
|
| 123 |
+
│ ├── app.py # FastAPI server (create_app entry point)
|
| 124 |
+
│ ├── hypothesis_lab_environment.py # Core environment logic
|
| 125 |
+
│ ├── causal_world.py # Hidden causal graph generator
|
| 126 |
+
│ └── rubric.py # Multi-component reward engine
|
| 127 |
+
├── tasks/
|
| 128 |
+
│ ├── __init__.py
|
| 129 |
+
│ ├── task_easy.py # Easy: 2 vars, low noise, 12 budget
|
| 130 |
+
│ ├── task_medium.py # Medium: 3 vars, medium noise, 10 budget
|
| 131 |
+
│ └── task_hard.py # Hard: 4 vars, high noise, 8 budget
|
| 132 |
+
└── tests/
|
| 133 |
+
├── __init__.py
|
| 134 |
+
└── test_environment.py # Unit + integration tests
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## Action Space
|
| 138 |
+
|
| 139 |
+
**HypLabAction** has two modes:
|
| 140 |
+
|
| 141 |
+
| Field | Type | Description |
|
| 142 |
+
|---|---|---|
|
| 143 |
+
| `action_type` | `"experiment"` or `"submit"` | What the agent is doing |
|
| 144 |
+
| `experiment_type` | `"intervention"`, `"correlation"`, `"counterfactual"`, `"passive"` | Experiment kind (experiment mode) |
|
| 145 |
+
| `control_variable` | `str` | Variable to set/vary |
|
| 146 |
+
| `control_value` | `float` | Value to set (intervention/counterfactual) |
|
| 147 |
+
| `control_range` | `[min, max, n]` | Sweep range (correlation only) |
|
| 148 |
+
| `target_variable` | `str` | Variable to observe |
|
| 149 |
+
| `hypothesis_text` | `str` | Free-text hypothesis (submit mode) |
|
| 150 |
+
| `hypothesis_equations` | `list[str]` | Structured equations (submit mode) |
|
| 151 |
+
| `confidence` | `float [0,1]` | Self-reported confidence (submit mode) |
|
| 152 |
+
|
| 153 |
+
## Observation Space
|
| 154 |
+
|
| 155 |
+
**HypLabObservation** always contains:
|
| 156 |
+
- `system_message`: Human-readable text the LLM reads
|
| 157 |
+
- `available_variables`: Variable names in this episode
|
| 158 |
+
- `budget_remaining`: Steps left
|
| 159 |
+
- `done`: Whether episode ended
|
| 160 |
+
- `reward`: Step reward
|
| 161 |
+
|
| 162 |
+
On experiment steps: `result_value`, `noise_sigma`, `info_gain_reward`, `is_redundant`
|
| 163 |
+
|
| 164 |
+
On submit: `accuracy_score`, `precision_bonus`, `calibration_score`, `efficiency_bonus`, `contradiction_penalty`, `total_episode_reward`, `ground_truth_revealed`
|
| 165 |
+
|
| 166 |
+
## Causal Rule Types
|
| 167 |
+
|
| 168 |
+
The hidden world can contain any of these relationship types:
|
| 169 |
+
|
| 170 |
+
| Rule | Formula | Shape |
|
| 171 |
+
|---|---|---|
|
| 172 |
+
| Linear | `y = a*x + b` | Straight line |
|
| 173 |
+
| Threshold | `y = high if x > t else low` | Step function |
|
| 174 |
+
| Inverse | `y = a / x` | Hyperbola |
|
| 175 |
+
| Quadratic | `y = a*x² + b*x + c` | Parabola |
|
| 176 |
+
| Exponential | `y = a * exp(k*x)` | Growth/decay |
|
| 177 |
+
| Logarithmic | `y = a * ln(x) + b` | Diminishing returns |
|
| 178 |
+
| Saturating | `y = Vmax * x / (Km + x)` | Plateau (Michaelis-Menten) |
|
| 179 |
+
| Piecewise-linear | Two slopes with a knot | Regime change |
|
| 180 |
+
|
| 181 |
+
Additionally, some effects may depend on **two parents** via interaction rules
|
| 182 |
+
(additive, multiplicative, min, max), and **hidden confounders** may inject
|
| 183 |
+
correlated noise the agent cannot explain.
|
| 184 |
+
|
| 185 |
+
## Reward Components
|
| 186 |
+
|
| 187 |
+
| Signal | Value | What it trains |
|
| 188 |
+
|---|---|---|
|
| 189 |
+
| Information gain | +0.05 to +0.25/step | Designing informative experiments |
|
| 190 |
+
| Redundant experiment | -0.10 | Not wasting budget |
|
| 191 |
+
| Hypothesis accuracy | 0.0 to +1.0 | Getting the right answer |
|
| 192 |
+
| Precision bonus | +0.10 | Quantitative, falsifiable claims |
|
| 193 |
+
| Calibration score | 0.0 to +0.20 | Knowing what you don't know |
|
| 194 |
+
| Efficiency bonus | +0.15 | Submitting early when confident |
|
| 195 |
+
| Contradiction penalty | -0.50 | Contradicting the experimental setup |
|
| 196 |
+
|
| 197 |
+
## Tasks (3 difficulty levels)
|
| 198 |
+
|
| 199 |
+
| Task | Noise | Variables | Budget | Domain | Key Challenge |
|
| 200 |
+
|---|---|---|---|---|---|
|
| 201 |
+
| Easy | 0.05 | 2 | 12 | system_alpha | Single-edge discovery |
|
| 202 |
+
| Medium | 0.20 | 3 | 10 | Random | Multi-edge, noisy signals |
|
| 203 |
+
| Hard | 0.50 | 4 | 8 | Random | Complex graph + interactions, tight budget |
|
| 204 |
+
|
| 205 |
+
Each task has a deterministic grader that returns a score in [0.0, 1.0].
|
| 206 |
+
|
| 207 |
+
## Design Decisions
|
| 208 |
+
|
| 209 |
+
**Abstract variable names:** Variables are named Alpha, Beta, Gamma (or V1, V2,
|
| 210 |
+
V3, etc.) rather than Temperature, Pressure, Volume. This prevents LLM agents
|
| 211 |
+
from using pretrained knowledge of real-world physics/economics/biology to
|
| 212 |
+
shortcut the reasoning process. The agent must reason purely from experimental
|
| 213 |
+
data.
|
| 214 |
+
|
| 215 |
+
**Diverse rule types:** With 8 single-parent types plus interaction rules, the
|
| 216 |
+
agent cannot memorize a small set of templates. Many rule types look similar in
|
| 217 |
+
narrow ranges (e.g. exponential ≈ linear for small x), forcing the agent to
|
| 218 |
+
design discriminating experiments.
|
| 219 |
+
|
| 220 |
+
## Deploy to HF Spaces
|
| 221 |
+
|
| 222 |
+
```bash
|
| 223 |
+
openenv push --org your-org --token $HF_TOKEN
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
## Run Tests
|
| 227 |
+
|
| 228 |
+
```bash
|
| 229 |
+
pytest tests/ -v
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
## Baseline Scores
|
| 233 |
+
|
| 234 |
+
Baseline agent (gpt-4o-mini, temperature=0.3):
|
| 235 |
+
|
| 236 |
+
| Task | Score |
|
| 237 |
+
|---|---|
|
| 238 |
+
| Easy | ~0.65 |
|
| 239 |
+
| Medium | ~0.40 |
|
| 240 |
+
| Hard | ~0.25 |
|
| 241 |
+
| Average | ~0.43 |
|
| 242 |
+
|
| 243 |
+
These scores are reproducible via `python baseline_inference.py` with the same model and seed.
|
hypothesis_lab.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
pyproject.toml
|
| 3 |
+
hypothesis_lab.egg-info/PKG-INFO
|
| 4 |
+
hypothesis_lab.egg-info/SOURCES.txt
|
| 5 |
+
hypothesis_lab.egg-info/dependency_links.txt
|
| 6 |
+
hypothesis_lab.egg-info/requires.txt
|
| 7 |
+
hypothesis_lab.egg-info/top_level.txt
|
| 8 |
+
server/__init__.py
|
| 9 |
+
server/app.py
|
| 10 |
+
server/causal_world.py
|
| 11 |
+
server/hypothesis_lab_environment.py
|
| 12 |
+
server/rubric.py
|
| 13 |
+
tasks/__init__.py
|
| 14 |
+
tasks/task_easy.py
|
| 15 |
+
tasks/task_hard.py
|
| 16 |
+
tasks/task_medium.py
|
| 17 |
+
tests/__init__.py
|
| 18 |
+
tests/test_environment.py
|
hypothesis_lab.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
hypothesis_lab.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core[core]>=0.2.1
|
| 2 |
+
fastapi>=0.111.0
|
| 3 |
+
uvicorn[standard]>=0.29.0
|
| 4 |
+
pydantic>=2.7.0
|
| 5 |
+
numpy>=1.26.0
|
| 6 |
+
networkx>=3.3
|
| 7 |
+
|
| 8 |
+
[baseline]
|
| 9 |
+
openai>=1.30.0
|
| 10 |
+
|
| 11 |
+
[dev]
|
| 12 |
+
pytest>=8.0
|
| 13 |
+
pytest-asyncio>=0.23.0
|
| 14 |
+
httpx>=0.27.0
|
| 15 |
+
ruff>=0.4.0
|
hypothesis_lab.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
server
|
| 2 |
+
tasks
|
| 3 |
+
tests
|
models.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
models.py -- Pydantic data models for the Scientific Hypothesis Lab.
|
| 3 |
+
|
| 4 |
+
Follows the OpenEnv spec: Action, Observation, and State base types from
|
| 5 |
+
openenv.core.env_server.types.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from enum import Enum
|
| 11 |
+
from typing import Any, Optional
|
| 12 |
+
|
| 13 |
+
from pydantic import Field
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 17 |
+
except ImportError:
|
| 18 |
+
from pydantic import BaseModel
|
| 19 |
+
|
| 20 |
+
class Action(BaseModel): # type: ignore[no-redef]
|
| 21 |
+
model_config = {"extra": "forbid"}
|
| 22 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 23 |
+
|
| 24 |
+
class Observation(BaseModel): # type: ignore[no-redef]
|
| 25 |
+
model_config = {"extra": "forbid"}
|
| 26 |
+
done: bool = False
|
| 27 |
+
reward: float | None = None
|
| 28 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 29 |
+
|
| 30 |
+
class State(BaseModel): # type: ignore[no-redef]
|
| 31 |
+
model_config = {"extra": "allow"}
|
| 32 |
+
episode_id: Optional[str] = None
|
| 33 |
+
step_count: int = 0
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class ExperimentType(str, Enum):
|
| 37 |
+
INTERVENTION = "intervention"
|
| 38 |
+
CORRELATION = "correlation"
|
| 39 |
+
COUNTERFACTUAL = "counterfactual"
|
| 40 |
+
PASSIVE = "passive"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ActionType(str, Enum):
|
| 44 |
+
EXPERIMENT = "experiment"
|
| 45 |
+
SUBMIT = "submit"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class NoiseLevelTag(str, Enum):
|
| 49 |
+
LOW = "low"
|
| 50 |
+
MEDIUM = "medium"
|
| 51 |
+
HIGH = "high"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class HypLabAction(Action):
|
| 55 |
+
"""
|
| 56 |
+
Every message the agent sends to the environment.
|
| 57 |
+
|
| 58 |
+
Two forms:
|
| 59 |
+
- action_type=EXPERIMENT: run an experiment, burn one budget step
|
| 60 |
+
- action_type=SUBMIT: commit to a hypothesis, end the episode
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
action_type: ActionType = Field(
|
| 64 |
+
...,
|
| 65 |
+
description="Whether the agent is running an experiment or submitting.",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
experiment_type: Optional[ExperimentType] = Field(
|
| 69 |
+
None, description="Which kind of experiment to run."
|
| 70 |
+
)
|
| 71 |
+
target_variable: Optional[str] = Field(
|
| 72 |
+
None, description="The variable the agent wants to observe."
|
| 73 |
+
)
|
| 74 |
+
control_variable: Optional[str] = Field(
|
| 75 |
+
None, description="The variable the agent is setting or varying."
|
| 76 |
+
)
|
| 77 |
+
control_value: Optional[float] = Field(
|
| 78 |
+
None,
|
| 79 |
+
description=(
|
| 80 |
+
"INTERVENTION: exact value to set. "
|
| 81 |
+
"COUNTERFACTUAL: the proposed delta. "
|
| 82 |
+
"Unused for PASSIVE."
|
| 83 |
+
),
|
| 84 |
+
)
|
| 85 |
+
control_range: Optional[list[float]] = Field(
|
| 86 |
+
None,
|
| 87 |
+
description="CORRELATION only: [min, max, n_points].",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
hypothesis_text: Optional[str] = Field(
|
| 91 |
+
None,
|
| 92 |
+
description="Free-text statement of discovered rules.",
|
| 93 |
+
)
|
| 94 |
+
hypothesis_equations: Optional[list[str]] = Field(
|
| 95 |
+
None,
|
| 96 |
+
description="Structured list of equations, one per rule.",
|
| 97 |
+
)
|
| 98 |
+
confidence: Optional[float] = Field(
|
| 99 |
+
None, ge=0.0, le=1.0,
|
| 100 |
+
description="Agent's self-reported confidence [0,1].",
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class HypLabObservation(Observation):
|
| 105 |
+
"""
|
| 106 |
+
Everything the environment hands back after reset() or step().
|
| 107 |
+
Inherits `done`, `reward`, `metadata` from Observation base.
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
+
system_message: str = Field(
|
| 111 |
+
..., description="Human-readable description of what just happened."
|
| 112 |
+
)
|
| 113 |
+
available_variables: list[str] = Field(
|
| 114 |
+
default_factory=list,
|
| 115 |
+
description="Names of all variables in the current hidden world.",
|
| 116 |
+
)
|
| 117 |
+
budget_remaining: int = Field(
|
| 118 |
+
0, description="Steps left before forced termination."
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
experiment_type_run: Optional[ExperimentType] = None
|
| 122 |
+
control_variable_used: Optional[str] = None
|
| 123 |
+
control_value_used: Optional[Any] = None
|
| 124 |
+
target_variable_observed: Optional[str] = None
|
| 125 |
+
result_value: Optional[Any] = Field(
|
| 126 |
+
None,
|
| 127 |
+
description="Noisy observed value(s). Float or list of (x,y) pairs.",
|
| 128 |
+
)
|
| 129 |
+
noise_sigma: Optional[float] = None
|
| 130 |
+
is_redundant: bool = False
|
| 131 |
+
info_gain_reward: float = 0.0
|
| 132 |
+
|
| 133 |
+
accuracy_score: Optional[float] = Field(None, ge=0, le=1)
|
| 134 |
+
precision_bonus: Optional[float] = None
|
| 135 |
+
calibration_score: Optional[float] = None
|
| 136 |
+
efficiency_bonus: Optional[float] = None
|
| 137 |
+
contradiction_penalty: Optional[float] = None
|
| 138 |
+
total_episode_reward: Optional[float] = None
|
| 139 |
+
ground_truth_revealed: Optional[str] = None
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class HypLabState(State):
|
| 143 |
+
"""
|
| 144 |
+
Snapshot of episode metadata. Never leaks the hidden causal graph.
|
| 145 |
+
Inherits `episode_id`, `step_count` from State base.
|
| 146 |
+
"""
|
| 147 |
+
|
| 148 |
+
budget_total: int = 0
|
| 149 |
+
budget_remaining: int = 0
|
| 150 |
+
noise_level: NoiseLevelTag = NoiseLevelTag.MEDIUM
|
| 151 |
+
noise_sigma: float = 0.20
|
| 152 |
+
domain: str = "unknown"
|
| 153 |
+
n_variables: int = 0
|
| 154 |
+
experiment_history: list[dict] = Field(default_factory=list)
|
| 155 |
+
cumulative_info_gain: float = 0.0
|
| 156 |
+
redundant_experiment_count: int = 0
|
openenv.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: hypothesis_lab
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
pyproject.toml
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "hypothesis-lab"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Scientific Hypothesis Lab -- OpenEnv RL environment for causal discovery under noise"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10"
|
| 11 |
+
license = { text = "MIT" }
|
| 12 |
+
|
| 13 |
+
dependencies = [
|
| 14 |
+
"openenv-core[core]>=0.2.1",
|
| 15 |
+
"fastapi>=0.111.0",
|
| 16 |
+
"uvicorn[standard]>=0.29.0",
|
| 17 |
+
"pydantic>=2.7.0",
|
| 18 |
+
"numpy>=1.26.0",
|
| 19 |
+
"networkx>=3.3",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
[project.optional-dependencies]
|
| 23 |
+
baseline = [
|
| 24 |
+
"openai>=1.30.0",
|
| 25 |
+
]
|
| 26 |
+
dev = [
|
| 27 |
+
"pytest>=8.0",
|
| 28 |
+
"pytest-asyncio>=0.23.0",
|
| 29 |
+
"httpx>=0.27.0",
|
| 30 |
+
"ruff>=0.4.0",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
[project.scripts]
|
| 34 |
+
server = "server.app:main"
|
| 35 |
+
|
| 36 |
+
[tool.setuptools.packages.find]
|
| 37 |
+
where = ["."]
|
| 38 |
+
include = ["server*", "tasks*", "tests*"]
|
| 39 |
+
|
| 40 |
+
[tool.pytest.ini_options]
|
| 41 |
+
asyncio_mode = "auto"
|
| 42 |
+
testpaths = ["tests"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core[core]>=0.2.1
|
| 2 |
+
fastapi>=0.111.0
|
| 3 |
+
uvicorn[standard]>=0.29.0
|
| 4 |
+
pydantic>=2.7.0
|
| 5 |
+
numpy>=1.26.0
|
| 6 |
+
networkx>=3.3
|
| 7 |
+
openai>=1.30.0
|
| 8 |
+
pytest>=8.0
|
| 9 |
+
pytest-asyncio>=0.23.0
|
server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Hypothesis Lab server components."""
|
server/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (216 Bytes). View file
|
|
|
server/__pycache__/app.cpython-311.pyc
ADDED
|
Binary file (1.91 kB). View file
|
|
|
server/__pycache__/causal_world.cpython-311.pyc
ADDED
|
Binary file (27.1 kB). View file
|
|
|
server/__pycache__/hypothesis_lab_environment.cpython-311.pyc
ADDED
|
Binary file (16 kB). View file
|
|
|
server/__pycache__/rubric.cpython-311.pyc
ADDED
|
Binary file (15 kB). View file
|
|
|
server/app.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
server/app.py -- FastAPI application for the Hypothesis Lab Environment.
|
| 3 |
+
|
| 4 |
+
Uses openenv's create_app() to produce the standard HTTP + WebSocket server
|
| 5 |
+
with /reset, /step, /state, /health, /schema, and /ws endpoints.
|
| 6 |
+
|
| 7 |
+
Additional endpoints for hackathon submission:
|
| 8 |
+
/tasks -- list all tasks and action schema
|
| 9 |
+
/grader -- score an episode result for a given task
|
| 10 |
+
/baseline -- trigger baseline inference and return scores
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import traceback
|
| 14 |
+
from typing import Any, Dict, Optional
|
| 15 |
+
|
| 16 |
+
from fastapi import Body
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from openenv.core.env_server.http_server import create_app
|
| 20 |
+
except ImportError:
|
| 21 |
+
raise ImportError(
|
| 22 |
+
"openenv-core is required. Install with: pip install openenv-core"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
from ..models import HypLabAction, HypLabObservation
|
| 27 |
+
from .hypothesis_lab_environment import HypothesisLabEnvironment
|
| 28 |
+
except ImportError:
|
| 29 |
+
from models import HypLabAction, HypLabObservation
|
| 30 |
+
from server.hypothesis_lab_environment import HypothesisLabEnvironment
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
app = create_app(
|
| 34 |
+
HypothesisLabEnvironment,
|
| 35 |
+
HypLabAction,
|
| 36 |
+
HypLabObservation,
|
| 37 |
+
env_name="hypothesis_lab",
|
| 38 |
+
max_concurrent_envs=200,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# /tasks -- list available tasks and the action schema
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
@app.get("/tasks", tags=["Hackathon"])
|
| 46 |
+
def list_tasks() -> Dict[str, Any]:
|
| 47 |
+
try:
|
| 48 |
+
from tasks import ALL_TASKS
|
| 49 |
+
except ImportError:
|
| 50 |
+
from tasks import ALL_TASKS # noqa: F811
|
| 51 |
+
|
| 52 |
+
action_schema = HypLabAction.model_json_schema()
|
| 53 |
+
|
| 54 |
+
return {
|
| 55 |
+
"tasks": [
|
| 56 |
+
{
|
| 57 |
+
"id": t["id"],
|
| 58 |
+
"name": t["name"],
|
| 59 |
+
"description": t["description"],
|
| 60 |
+
"difficulty": t["difficulty"],
|
| 61 |
+
"reset_kwargs": t["reset_kwargs"],
|
| 62 |
+
}
|
| 63 |
+
for t in ALL_TASKS
|
| 64 |
+
],
|
| 65 |
+
"action_schema": action_schema,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
# /grader -- score an episode result for a specific task
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
@app.post("/grader", tags=["Hackathon"])
|
| 73 |
+
def grade_episode(
|
| 74 |
+
body: Dict[str, Any] = Body(
|
| 75 |
+
...,
|
| 76 |
+
examples=[{
|
| 77 |
+
"task_id": "easy",
|
| 78 |
+
"episode_result": {
|
| 79 |
+
"accuracy_score": 0.7,
|
| 80 |
+
"precision_bonus": 0.1,
|
| 81 |
+
"calibration_score": 0.15,
|
| 82 |
+
"efficiency_bonus": 0.1,
|
| 83 |
+
"contradiction_penalty": 0.0,
|
| 84 |
+
},
|
| 85 |
+
}],
|
| 86 |
+
),
|
| 87 |
+
) -> Dict[str, Any]:
|
| 88 |
+
from tasks.task_easy import grade_easy
|
| 89 |
+
from tasks.task_medium import grade_medium
|
| 90 |
+
from tasks.task_hard import grade_hard
|
| 91 |
+
|
| 92 |
+
graders = {"easy": grade_easy, "medium": grade_medium, "hard": grade_hard}
|
| 93 |
+
|
| 94 |
+
task_id = body.get("task_id", "")
|
| 95 |
+
episode_result = body.get("episode_result", {})
|
| 96 |
+
|
| 97 |
+
if task_id not in graders:
|
| 98 |
+
return {"error": f"Unknown task_id '{task_id}'. Choose from: {list(graders.keys())}"}
|
| 99 |
+
|
| 100 |
+
score = graders[task_id](episode_result)
|
| 101 |
+
return {"task_id": task_id, "score": score}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
# /baseline -- run the baseline agent on all tasks and return scores
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
@app.post("/baseline", tags=["Hackathon"])
|
| 108 |
+
def run_baseline(
|
| 109 |
+
body: Optional[Dict[str, Any]] = Body(default=None),
|
| 110 |
+
) -> Dict[str, Any]:
|
| 111 |
+
try:
|
| 112 |
+
from baseline_inference import run_all_tasks
|
| 113 |
+
except ImportError:
|
| 114 |
+
return {"error": "baseline_inference module not found or missing dependencies (openai)."}
|
| 115 |
+
except Exception as e:
|
| 116 |
+
return {"error": f"Failed to import baseline: {e}"}
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
results = run_all_tasks()
|
| 120 |
+
return {"status": "ok", "results": results}
|
| 121 |
+
except Exception as e:
|
| 122 |
+
return {"error": str(e), "traceback": traceback.format_exc()}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 126 |
+
import uvicorn
|
| 127 |
+
uvicorn.run(app, host=host, port=port)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
if __name__ == "__main__":
|
| 131 |
+
main()
|
server/causal_world.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
server/causal_world.py -- The hidden causal world.
|
| 3 |
+
|
| 4 |
+
Every episode a new CausalGraph is generated. The agent never sees this
|
| 5 |
+
object -- it can only probe it via experiments.
|
| 6 |
+
|
| 7 |
+
Design:
|
| 8 |
+
- Variables are nodes; directed edges carry one of 8+ rule types.
|
| 9 |
+
- Multi-parent interaction rules make some effects depend on >1 cause.
|
| 10 |
+
- Hidden confounders can inject correlated noise across variables.
|
| 11 |
+
- Gaussian noise is added to every observation.
|
| 12 |
+
- The graph is a DAG (no cycles) so causality is well-defined.
|
| 13 |
+
- Domains: system_alpha | system_beta | system_gamma | system_delta
|
| 14 |
+
Each domain provides a different narrative prompt but uses abstract
|
| 15 |
+
variable names (Greek letters, V1/V2/V3...) to prevent LLM agents
|
| 16 |
+
from leveraging pretrained real-world knowledge instead of reasoning
|
| 17 |
+
from experimental evidence.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import math
|
| 23 |
+
import random
|
| 24 |
+
from dataclasses import dataclass, field
|
| 25 |
+
from typing import Any, Optional
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import networkx as nx
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
ABSTRACT_VAR_POOLS: list[list[str]] = [
|
| 32 |
+
["Alpha", "Beta", "Gamma", "Delta", "Epsilon"],
|
| 33 |
+
["Zeta", "Eta", "Theta", "Iota", "Kappa"],
|
| 34 |
+
["V1", "V2", "V3", "V4", "V5"],
|
| 35 |
+
["Rho", "Sigma", "Tau", "Upsilon", "Phi"],
|
| 36 |
+
["Mu", "Nu", "Xi", "Omicron", "Pi"],
|
| 37 |
+
["Quant_A", "Quant_B", "Quant_C", "Quant_D", "Quant_E"],
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
DOMAIN_LABELS: dict[str, dict] = {
|
| 41 |
+
"system_alpha": {
|
| 42 |
+
"context": "You are studying an unknown dynamical system. Variables have hidden causal relationships you must discover through experiments.",
|
| 43 |
+
"unit": "units",
|
| 44 |
+
},
|
| 45 |
+
"system_beta": {
|
| 46 |
+
"context": "You are investigating a black-box system with interacting quantities. Design experiments to uncover the governing equations.",
|
| 47 |
+
"unit": "units",
|
| 48 |
+
},
|
| 49 |
+
"system_gamma": {
|
| 50 |
+
"context": "You are analysing an opaque process with measurable outputs. Run controlled experiments to determine how variables influence each other.",
|
| 51 |
+
"unit": "units",
|
| 52 |
+
},
|
| 53 |
+
"system_delta": {
|
| 54 |
+
"context": "You are probing a simulated environment with coupled variables. The underlying rules are unknown -- discover them.",
|
| 55 |
+
"unit": "units",
|
| 56 |
+
},
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
DOMAINS = list(DOMAIN_LABELS.keys())
|
| 60 |
+
|
| 61 |
+
RULE_TYPES = [
|
| 62 |
+
"linear",
|
| 63 |
+
"threshold",
|
| 64 |
+
"inverse",
|
| 65 |
+
"quadratic",
|
| 66 |
+
"exponential",
|
| 67 |
+
"logarithmic",
|
| 68 |
+
"saturating",
|
| 69 |
+
"piecewise_linear",
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@dataclass
|
| 74 |
+
class CausalRule:
|
| 75 |
+
"""A single edge rule in the causal graph."""
|
| 76 |
+
|
| 77 |
+
cause: str
|
| 78 |
+
effect: str
|
| 79 |
+
rule_type: str
|
| 80 |
+
params: dict[str, float] = field(default_factory=dict)
|
| 81 |
+
description: str = ""
|
| 82 |
+
|
| 83 |
+
def evaluate(self, x: float) -> float:
|
| 84 |
+
if self.rule_type == "linear":
|
| 85 |
+
a = self.params.get("a", 1.0)
|
| 86 |
+
b = self.params.get("b", 0.0)
|
| 87 |
+
return a * x + b
|
| 88 |
+
|
| 89 |
+
elif self.rule_type == "threshold":
|
| 90 |
+
threshold = self.params.get("threshold", 5.0)
|
| 91 |
+
high = self.params.get("high", 10.0)
|
| 92 |
+
low = self.params.get("low", 2.0)
|
| 93 |
+
return high if x > threshold else low
|
| 94 |
+
|
| 95 |
+
elif self.rule_type == "inverse":
|
| 96 |
+
a = self.params.get("a", 10.0)
|
| 97 |
+
if abs(x) < 1e-9:
|
| 98 |
+
return float("nan")
|
| 99 |
+
return a / x
|
| 100 |
+
|
| 101 |
+
elif self.rule_type == "quadratic":
|
| 102 |
+
a = self.params.get("a", 0.5)
|
| 103 |
+
b = self.params.get("b", 0.0)
|
| 104 |
+
c = self.params.get("c", 0.0)
|
| 105 |
+
return a * x * x + b * x + c
|
| 106 |
+
|
| 107 |
+
elif self.rule_type == "exponential":
|
| 108 |
+
a = self.params.get("a", 1.0)
|
| 109 |
+
k = self.params.get("k", 0.3)
|
| 110 |
+
x_clamped = max(-20.0, min(20.0, k * x))
|
| 111 |
+
return a * math.exp(x_clamped)
|
| 112 |
+
|
| 113 |
+
elif self.rule_type == "logarithmic":
|
| 114 |
+
a = self.params.get("a", 3.0)
|
| 115 |
+
b = self.params.get("b", 0.0)
|
| 116 |
+
if x <= 0:
|
| 117 |
+
return float("nan")
|
| 118 |
+
return a * math.log(x) + b
|
| 119 |
+
|
| 120 |
+
elif self.rule_type == "saturating":
|
| 121 |
+
v_max = self.params.get("v_max", 10.0)
|
| 122 |
+
k_m = self.params.get("k_m", 3.0)
|
| 123 |
+
if x < 0:
|
| 124 |
+
return 0.0
|
| 125 |
+
return v_max * x / (k_m + x)
|
| 126 |
+
|
| 127 |
+
elif self.rule_type == "piecewise_linear":
|
| 128 |
+
knot = self.params.get("knot", 5.0)
|
| 129 |
+
a1 = self.params.get("a1", 1.0)
|
| 130 |
+
a2 = self.params.get("a2", -0.5)
|
| 131 |
+
b = self.params.get("b", 0.0)
|
| 132 |
+
if x <= knot:
|
| 133 |
+
return a1 * x + b
|
| 134 |
+
else:
|
| 135 |
+
y_knot = a1 * knot + b
|
| 136 |
+
return y_knot + a2 * (x - knot)
|
| 137 |
+
|
| 138 |
+
return 0.0
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@dataclass
|
| 142 |
+
class InteractionRule:
|
| 143 |
+
"""
|
| 144 |
+
A multi-parent rule: effect = f(cause1, cause2).
|
| 145 |
+
These cannot be discovered by varying one variable at a time --
|
| 146 |
+
the agent must realise two parents jointly determine the effect.
|
| 147 |
+
"""
|
| 148 |
+
|
| 149 |
+
cause1: str
|
| 150 |
+
cause2: str
|
| 151 |
+
effect: str
|
| 152 |
+
interaction_type: str # "additive", "multiplicative", "min", "max"
|
| 153 |
+
params: dict[str, float] = field(default_factory=dict)
|
| 154 |
+
description: str = ""
|
| 155 |
+
|
| 156 |
+
def evaluate(self, x1: float, x2: float) -> float:
|
| 157 |
+
if self.interaction_type == "additive":
|
| 158 |
+
a = self.params.get("a", 1.0)
|
| 159 |
+
b = self.params.get("b", 1.0)
|
| 160 |
+
c = self.params.get("c", 0.0)
|
| 161 |
+
return a * x1 + b * x2 + c
|
| 162 |
+
elif self.interaction_type == "multiplicative":
|
| 163 |
+
a = self.params.get("a", 0.5)
|
| 164 |
+
return a * x1 * x2
|
| 165 |
+
elif self.interaction_type == "min":
|
| 166 |
+
return min(x1, x2)
|
| 167 |
+
elif self.interaction_type == "max":
|
| 168 |
+
return max(x1, x2)
|
| 169 |
+
return 0.0
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@dataclass
|
| 173 |
+
class CausalWorld:
|
| 174 |
+
"""
|
| 175 |
+
The hidden world the agent must discover.
|
| 176 |
+
Contains variables, single-parent rules, multi-parent interaction rules,
|
| 177 |
+
and optional hidden confounders.
|
| 178 |
+
"""
|
| 179 |
+
|
| 180 |
+
domain: str
|
| 181 |
+
variables: list[str]
|
| 182 |
+
units: dict[str, str]
|
| 183 |
+
rules: list[CausalRule]
|
| 184 |
+
default_values: dict[str, float]
|
| 185 |
+
rng: np.random.Generator
|
| 186 |
+
interactions: list[InteractionRule] = field(default_factory=list)
|
| 187 |
+
confounder_sigma: float = 0.0
|
| 188 |
+
|
| 189 |
+
def _compute_value(
|
| 190 |
+
self, target: str, interventions: Optional[dict[str, float]] = None
|
| 191 |
+
) -> float:
|
| 192 |
+
"""Compute the true (noiseless) value of target given interventions."""
|
| 193 |
+
vals = dict(self.default_values)
|
| 194 |
+
if interventions:
|
| 195 |
+
vals.update(interventions)
|
| 196 |
+
|
| 197 |
+
for rule in self.rules:
|
| 198 |
+
if rule.effect in (interventions or {}):
|
| 199 |
+
continue
|
| 200 |
+
if rule.cause in vals:
|
| 201 |
+
result = rule.evaluate(vals[rule.cause])
|
| 202 |
+
if not math.isnan(result):
|
| 203 |
+
vals[rule.effect] = result
|
| 204 |
+
|
| 205 |
+
for inter in self.interactions:
|
| 206 |
+
if inter.effect in (interventions or {}):
|
| 207 |
+
continue
|
| 208 |
+
if inter.cause1 in vals and inter.cause2 in vals:
|
| 209 |
+
result = inter.evaluate(vals[inter.cause1], vals[inter.cause2])
|
| 210 |
+
vals[inter.effect] = result
|
| 211 |
+
|
| 212 |
+
return vals.get(target, 0.0)
|
| 213 |
+
|
| 214 |
+
def _confounder_noise(self) -> float:
|
| 215 |
+
"""Hidden confounder adds correlated noise the agent can't explain."""
|
| 216 |
+
if self.confounder_sigma <= 0:
|
| 217 |
+
return 0.0
|
| 218 |
+
return float(self.rng.normal(0, self.confounder_sigma))
|
| 219 |
+
|
| 220 |
+
def query_intervention(
|
| 221 |
+
self, cause: str, value: float, effect: str, sigma: float
|
| 222 |
+
) -> float:
|
| 223 |
+
true_val = self._compute_value(effect, {cause: value})
|
| 224 |
+
return true_val + self.rng.normal(0, sigma) + self._confounder_noise()
|
| 225 |
+
|
| 226 |
+
def query_correlation(
|
| 227 |
+
self,
|
| 228 |
+
cause: str,
|
| 229 |
+
control_range: list[float],
|
| 230 |
+
effect: str,
|
| 231 |
+
sigma: float,
|
| 232 |
+
) -> list[tuple[float, float]]:
|
| 233 |
+
lo = control_range[0] if len(control_range) > 0 else 1.0
|
| 234 |
+
hi = control_range[1] if len(control_range) > 1 else 10.0
|
| 235 |
+
n = int(control_range[2]) if len(control_range) > 2 else 5
|
| 236 |
+
xs = np.linspace(lo, hi, n)
|
| 237 |
+
conf = self._confounder_noise()
|
| 238 |
+
pairs = []
|
| 239 |
+
for x in xs:
|
| 240 |
+
y = self._compute_value(effect, {cause: float(x)})
|
| 241 |
+
y_noisy = y + self.rng.normal(0, sigma) + conf
|
| 242 |
+
pairs.append((round(float(x), 4), round(float(y_noisy), 4)))
|
| 243 |
+
return pairs
|
| 244 |
+
|
| 245 |
+
def query_counterfactual(
|
| 246 |
+
self, cause: str, delta: float, effect: str, sigma: float
|
| 247 |
+
) -> dict[str, Any]:
|
| 248 |
+
baseline_x = self.default_values.get(cause, 5.0)
|
| 249 |
+
cf_x = baseline_x + delta
|
| 250 |
+
baseline_y = self._compute_value(effect, {cause: baseline_x})
|
| 251 |
+
cf_y = self._compute_value(effect, {cause: cf_x})
|
| 252 |
+
conf = self._confounder_noise()
|
| 253 |
+
baseline_y_noisy = baseline_y + self.rng.normal(0, sigma) + conf
|
| 254 |
+
cf_y_noisy = cf_y + self.rng.normal(0, sigma) + conf
|
| 255 |
+
direction = "increases" if cf_y > baseline_y else "decreases" if cf_y < baseline_y else "unchanged"
|
| 256 |
+
return {
|
| 257 |
+
"baseline_x": round(baseline_x, 4),
|
| 258 |
+
"baseline_y_noisy": round(float(baseline_y_noisy), 4),
|
| 259 |
+
"counterfactual_x": round(cf_x, 4),
|
| 260 |
+
"counterfactual_y_noisy": round(float(cf_y_noisy), 4),
|
| 261 |
+
"direction": direction,
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
def query_passive(self, target: str, sigma: float) -> float:
|
| 265 |
+
true_val = self._compute_value(target)
|
| 266 |
+
return true_val + self.rng.normal(0, sigma) + self._confounder_noise()
|
| 267 |
+
|
| 268 |
+
def ground_truth_summary(self) -> str:
|
| 269 |
+
lines = [f"Domain: {self.domain}"]
|
| 270 |
+
effects_covered: set[str] = set()
|
| 271 |
+
for rule in self.rules:
|
| 272 |
+
lines.append(f" {rule.description}")
|
| 273 |
+
effects_covered.add(rule.effect)
|
| 274 |
+
for inter in self.interactions:
|
| 275 |
+
lines.append(f" {inter.description}")
|
| 276 |
+
effects_covered.add(inter.effect)
|
| 277 |
+
for v in self.variables:
|
| 278 |
+
if v not in effects_covered:
|
| 279 |
+
lines.append(f" {v} = {self.default_values.get(v, '?')} (root)")
|
| 280 |
+
if self.confounder_sigma > 0:
|
| 281 |
+
lines.append(f" [hidden confounder with sigma={self.confounder_sigma:.2f}]")
|
| 282 |
+
return "\n".join(lines)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def _random_rule(
|
| 286 |
+
cause: str, effect: str, rng: random.Random
|
| 287 |
+
) -> CausalRule:
|
| 288 |
+
"""Generate a random causal rule for one edge."""
|
| 289 |
+
rule_type = rng.choices(
|
| 290 |
+
RULE_TYPES,
|
| 291 |
+
weights=[0.30, 0.15, 0.10, 0.12, 0.08, 0.08, 0.10, 0.07],
|
| 292 |
+
)[0]
|
| 293 |
+
|
| 294 |
+
if rule_type == "linear":
|
| 295 |
+
a = round(rng.uniform(0.5, 3.5) * rng.choice([-1, 1]), 2)
|
| 296 |
+
b = round(rng.uniform(-5.0, 5.0), 2)
|
| 297 |
+
sign = "+" if b >= 0 else "-"
|
| 298 |
+
desc = f"{effect} = {a} * {cause} {sign} {abs(b)}"
|
| 299 |
+
return CausalRule(cause, effect, "linear", {"a": a, "b": b}, desc)
|
| 300 |
+
|
| 301 |
+
elif rule_type == "threshold":
|
| 302 |
+
threshold = round(rng.uniform(3.0, 8.0), 2)
|
| 303 |
+
high = round(rng.uniform(6.0, 12.0), 2)
|
| 304 |
+
low = round(rng.uniform(0.5, 4.0), 2)
|
| 305 |
+
desc = f"{effect} = {high} if {cause} > {threshold} else {low}"
|
| 306 |
+
return CausalRule(
|
| 307 |
+
cause, effect, "threshold",
|
| 308 |
+
{"threshold": threshold, "high": high, "low": low}, desc,
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
elif rule_type == "inverse":
|
| 312 |
+
a = round(rng.uniform(5.0, 30.0), 2)
|
| 313 |
+
desc = f"{effect} = {a} / {cause}"
|
| 314 |
+
return CausalRule(cause, effect, "inverse", {"a": a}, desc)
|
| 315 |
+
|
| 316 |
+
elif rule_type == "quadratic":
|
| 317 |
+
a = round(rng.uniform(0.1, 1.0) * rng.choice([-1, 1]), 2)
|
| 318 |
+
b = round(rng.uniform(-2.0, 2.0), 2)
|
| 319 |
+
c = round(rng.uniform(-3.0, 3.0), 2)
|
| 320 |
+
desc = f"{effect} = {a}*{cause}^2 + {b}*{cause} + {c}"
|
| 321 |
+
return CausalRule(cause, effect, "quadratic", {"a": a, "b": b, "c": c}, desc)
|
| 322 |
+
|
| 323 |
+
elif rule_type == "exponential":
|
| 324 |
+
a = round(rng.uniform(0.5, 3.0), 2)
|
| 325 |
+
k = round(rng.uniform(0.1, 0.5) * rng.choice([-1, 1]), 2)
|
| 326 |
+
desc = f"{effect} = {a} * exp({k} * {cause})"
|
| 327 |
+
return CausalRule(cause, effect, "exponential", {"a": a, "k": k}, desc)
|
| 328 |
+
|
| 329 |
+
elif rule_type == "logarithmic":
|
| 330 |
+
a = round(rng.uniform(1.0, 5.0) * rng.choice([-1, 1]), 2)
|
| 331 |
+
b = round(rng.uniform(-3.0, 3.0), 2)
|
| 332 |
+
sign = "+" if b >= 0 else "-"
|
| 333 |
+
desc = f"{effect} = {a} * ln({cause}) {sign} {abs(b)}"
|
| 334 |
+
return CausalRule(cause, effect, "logarithmic", {"a": a, "b": b}, desc)
|
| 335 |
+
|
| 336 |
+
elif rule_type == "saturating":
|
| 337 |
+
v_max = round(rng.uniform(5.0, 15.0), 2)
|
| 338 |
+
k_m = round(rng.uniform(1.0, 6.0), 2)
|
| 339 |
+
desc = f"{effect} = {v_max} * {cause} / ({k_m} + {cause})"
|
| 340 |
+
return CausalRule(cause, effect, "saturating", {"v_max": v_max, "k_m": k_m}, desc)
|
| 341 |
+
|
| 342 |
+
else: # piecewise_linear
|
| 343 |
+
knot = round(rng.uniform(3.0, 7.0), 2)
|
| 344 |
+
a1 = round(rng.uniform(0.5, 3.0) * rng.choice([-1, 1]), 2)
|
| 345 |
+
a2 = round(rng.uniform(0.5, 3.0) * rng.choice([-1, 1]), 2)
|
| 346 |
+
b = round(rng.uniform(-3.0, 3.0), 2)
|
| 347 |
+
desc = (
|
| 348 |
+
f"{effect} = {a1}*{cause} + {b} (if {cause} <= {knot}), "
|
| 349 |
+
f"then slope changes to {a2}"
|
| 350 |
+
)
|
| 351 |
+
return CausalRule(
|
| 352 |
+
cause, effect, "piecewise_linear",
|
| 353 |
+
{"knot": knot, "a1": a1, "a2": a2, "b": b}, desc,
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def _random_interaction(
|
| 358 |
+
cause1: str, cause2: str, effect: str, rng: random.Random
|
| 359 |
+
) -> InteractionRule:
|
| 360 |
+
"""Generate a random interaction rule where effect depends on two parents."""
|
| 361 |
+
itype = rng.choices(
|
| 362 |
+
["additive", "multiplicative", "min", "max"],
|
| 363 |
+
weights=[0.35, 0.35, 0.15, 0.15],
|
| 364 |
+
)[0]
|
| 365 |
+
|
| 366 |
+
if itype == "additive":
|
| 367 |
+
a = round(rng.uniform(0.5, 2.0) * rng.choice([-1, 1]), 2)
|
| 368 |
+
b = round(rng.uniform(0.5, 2.0) * rng.choice([-1, 1]), 2)
|
| 369 |
+
c = round(rng.uniform(-2.0, 2.0), 2)
|
| 370 |
+
desc = f"{effect} = {a}*{cause1} + {b}*{cause2} + {c}"
|
| 371 |
+
return InteractionRule(cause1, cause2, effect, itype, {"a": a, "b": b, "c": c}, desc)
|
| 372 |
+
elif itype == "multiplicative":
|
| 373 |
+
a = round(rng.uniform(0.1, 0.8), 2)
|
| 374 |
+
desc = f"{effect} = {a} * {cause1} * {cause2}"
|
| 375 |
+
return InteractionRule(cause1, cause2, effect, itype, {"a": a}, desc)
|
| 376 |
+
elif itype == "min":
|
| 377 |
+
desc = f"{effect} = min({cause1}, {cause2})"
|
| 378 |
+
return InteractionRule(cause1, cause2, effect, itype, {}, desc)
|
| 379 |
+
else:
|
| 380 |
+
desc = f"{effect} = max({cause1}, {cause2})"
|
| 381 |
+
return InteractionRule(cause1, cause2, effect, itype, {}, desc)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def generate_world(
|
| 385 |
+
n_variables: int = 3,
|
| 386 |
+
domain: Optional[str] = None,
|
| 387 |
+
seed: Optional[int] = None,
|
| 388 |
+
) -> CausalWorld:
|
| 389 |
+
"""
|
| 390 |
+
Generate a fresh hidden causal world.
|
| 391 |
+
|
| 392 |
+
The world may contain:
|
| 393 |
+
- Single-parent rules (8 types: linear, threshold, inverse, quadratic,
|
| 394 |
+
exponential, logarithmic, saturating, piecewise_linear)
|
| 395 |
+
- Multi-parent interaction rules (additive, multiplicative, min, max)
|
| 396 |
+
- Hidden confounders that add unexplainable correlated noise
|
| 397 |
+
|
| 398 |
+
Args:
|
| 399 |
+
n_variables: How many variables (2-5).
|
| 400 |
+
domain: One of "system_alpha", "system_beta", "system_gamma", "system_delta", or None for random.
|
| 401 |
+
seed: Random seed for reproducibility.
|
| 402 |
+
|
| 403 |
+
Returns:
|
| 404 |
+
A CausalWorld instance ready for agent probing.
|
| 405 |
+
"""
|
| 406 |
+
py_rng = random.Random(seed)
|
| 407 |
+
np_rng = np.random.default_rng(seed)
|
| 408 |
+
|
| 409 |
+
if domain is None or domain not in DOMAIN_LABELS:
|
| 410 |
+
domain = py_rng.choice(DOMAINS)
|
| 411 |
+
|
| 412 |
+
labels = DOMAIN_LABELS[domain]
|
| 413 |
+
var_pool = py_rng.choice(ABSTRACT_VAR_POOLS)
|
| 414 |
+
n = min(n_variables, len(var_pool))
|
| 415 |
+
chosen_vars = py_rng.sample(var_pool, n)
|
| 416 |
+
unit_label = labels.get("unit", "units")
|
| 417 |
+
units = {v: unit_label for v in chosen_vars}
|
| 418 |
+
|
| 419 |
+
rules: list[CausalRule] = []
|
| 420 |
+
for i in range(len(chosen_vars) - 1):
|
| 421 |
+
parent = chosen_vars[i]
|
| 422 |
+
child = chosen_vars[i + 1]
|
| 423 |
+
rules.append(_random_rule(parent, child, py_rng))
|
| 424 |
+
|
| 425 |
+
for i, parent in enumerate(chosen_vars):
|
| 426 |
+
for j, child in enumerate(chosen_vars):
|
| 427 |
+
if j <= i + 1:
|
| 428 |
+
continue
|
| 429 |
+
if py_rng.random() < 0.30:
|
| 430 |
+
rules.append(_random_rule(parent, child, py_rng))
|
| 431 |
+
|
| 432 |
+
interactions: list[InteractionRule] = []
|
| 433 |
+
if n >= 3 and py_rng.random() < 0.40:
|
| 434 |
+
roots_and_mid = [v for v in chosen_vars[:n - 1]]
|
| 435 |
+
if len(roots_and_mid) >= 2:
|
| 436 |
+
c1, c2 = py_rng.sample(roots_and_mid, 2)
|
| 437 |
+
target = chosen_vars[-1]
|
| 438 |
+
interaction = _random_interaction(c1, c2, target, py_rng)
|
| 439 |
+
interactions.append(interaction)
|
| 440 |
+
rules = [r for r in rules if r.effect != target]
|
| 441 |
+
|
| 442 |
+
confounder_sigma = 0.0
|
| 443 |
+
if n >= 3 and py_rng.random() < 0.30:
|
| 444 |
+
confounder_sigma = round(py_rng.uniform(0.05, 0.25), 3)
|
| 445 |
+
|
| 446 |
+
all_effects = {r.effect for r in rules} | {i.effect for i in interactions}
|
| 447 |
+
default_values: dict[str, float] = {}
|
| 448 |
+
for v in chosen_vars:
|
| 449 |
+
is_root = v not in all_effects
|
| 450 |
+
default_values[v] = round(py_rng.uniform(2.0, 10.0), 2) if is_root else 0.0
|
| 451 |
+
|
| 452 |
+
display_order = list(chosen_vars)
|
| 453 |
+
py_rng.shuffle(display_order)
|
| 454 |
+
|
| 455 |
+
world = CausalWorld(
|
| 456 |
+
domain=domain,
|
| 457 |
+
variables=display_order,
|
| 458 |
+
units=units,
|
| 459 |
+
rules=rules,
|
| 460 |
+
default_values=default_values,
|
| 461 |
+
rng=np_rng,
|
| 462 |
+
interactions=interactions,
|
| 463 |
+
confounder_sigma=confounder_sigma,
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
for v in chosen_vars:
|
| 467 |
+
if default_values[v] == 0.0:
|
| 468 |
+
computed = world._compute_value(v)
|
| 469 |
+
if not math.isnan(computed):
|
| 470 |
+
default_values[v] = round(computed, 4)
|
| 471 |
+
else:
|
| 472 |
+
default_values[v] = round(py_rng.uniform(2.0, 10.0), 2)
|
| 473 |
+
|
| 474 |
+
return world
|
server/hypothesis_lab_environment.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
server/hypothesis_lab_environment.py -- OpenEnv Environment implementation.
|
| 3 |
+
|
| 4 |
+
Implements the OpenEnv server-side Environment interface:
|
| 5 |
+
reset() -> initial observation
|
| 6 |
+
step() -> execute one agent action, return observation
|
| 7 |
+
state -> return episode metadata (no hidden info leaked)
|
| 8 |
+
|
| 9 |
+
This class is what the FastAPI server wraps via create_app().
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import random
|
| 15 |
+
from typing import Any, Optional
|
| 16 |
+
from uuid import uuid4
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from openenv.core.env_server.interfaces import Environment
|
| 20 |
+
except ImportError:
|
| 21 |
+
from abc import ABC, abstractmethod
|
| 22 |
+
|
| 23 |
+
class Environment(ABC): # type: ignore[no-redef]
|
| 24 |
+
def __init__(self, **kwargs: Any):
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
@abstractmethod
|
| 28 |
+
def reset(self, **kwargs: Any) -> Any:
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
@abstractmethod
|
| 32 |
+
def step(self, action: Any, **kwargs: Any) -> Any:
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
@abstractmethod
|
| 37 |
+
def state(self) -> Any:
|
| 38 |
+
pass
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
from models import (
|
| 42 |
+
ActionType,
|
| 43 |
+
ExperimentType,
|
| 44 |
+
HypLabAction,
|
| 45 |
+
HypLabObservation,
|
| 46 |
+
HypLabState,
|
| 47 |
+
NoiseLevelTag,
|
| 48 |
+
)
|
| 49 |
+
except ImportError:
|
| 50 |
+
from ..models import (
|
| 51 |
+
ActionType,
|
| 52 |
+
ExperimentType,
|
| 53 |
+
HypLabAction,
|
| 54 |
+
HypLabObservation,
|
| 55 |
+
HypLabState,
|
| 56 |
+
NoiseLevelTag,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
from .causal_world import CausalWorld, generate_world
|
| 60 |
+
from .rubric import InfoGainTracker, RubricResult, score_hypothesis
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
NOISE_SCHEDULE: dict[NoiseLevelTag, float] = {
|
| 64 |
+
NoiseLevelTag.LOW: 0.05,
|
| 65 |
+
NoiseLevelTag.MEDIUM: 0.20,
|
| 66 |
+
NoiseLevelTag.HIGH: 0.50,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
BUDGET_SCHEDULE: dict[NoiseLevelTag, int] = {
|
| 70 |
+
NoiseLevelTag.LOW: 12,
|
| 71 |
+
NoiseLevelTag.MEDIUM: 10,
|
| 72 |
+
NoiseLevelTag.HIGH: 8,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
N_VARIABLES_SCHEDULE: dict[NoiseLevelTag, int] = {
|
| 76 |
+
NoiseLevelTag.LOW: 2,
|
| 77 |
+
NoiseLevelTag.MEDIUM: 3,
|
| 78 |
+
NoiseLevelTag.HIGH: 4,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
DOMAINS = ["system_alpha", "system_beta", "system_gamma", "system_delta"]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class HypothesisLabEnvironment(Environment):
|
| 85 |
+
"""
|
| 86 |
+
Scientific Hypothesis Lab -- OpenEnv Environment.
|
| 87 |
+
|
| 88 |
+
Each episode presents the agent with a new randomised causal world.
|
| 89 |
+
The agent must discover the hidden rules through experiments and
|
| 90 |
+
submit a hypothesis before running out of budget.
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 94 |
+
|
| 95 |
+
def __init__(self, **kwargs: Any):
|
| 96 |
+
super().__init__(**kwargs)
|
| 97 |
+
self._episode_id: str = ""
|
| 98 |
+
self._world: Optional[CausalWorld] = None
|
| 99 |
+
self._tracker: Optional[InfoGainTracker] = None
|
| 100 |
+
self._step_count: int = 0
|
| 101 |
+
self._budget_total: int = 10
|
| 102 |
+
self._budget_remaining: int = 0
|
| 103 |
+
self._done: bool = True
|
| 104 |
+
self._history: list[dict] = []
|
| 105 |
+
self._cumulative_reward: float = 0.0
|
| 106 |
+
self._noise_level: NoiseLevelTag = NoiseLevelTag.MEDIUM
|
| 107 |
+
self._sigma: float = 0.20
|
| 108 |
+
self._domain: str = "unknown"
|
| 109 |
+
|
| 110 |
+
def reset(
|
| 111 |
+
self,
|
| 112 |
+
seed: Optional[int] = None,
|
| 113 |
+
episode_id: Optional[str] = None,
|
| 114 |
+
**kwargs: Any,
|
| 115 |
+
) -> HypLabObservation:
|
| 116 |
+
noise_level_str = kwargs.get("noise_level", "medium")
|
| 117 |
+
noise_level = NoiseLevelTag(noise_level_str) if isinstance(noise_level_str, str) else noise_level_str
|
| 118 |
+
domain = kwargs.get("domain", None) or random.choice(DOMAINS)
|
| 119 |
+
|
| 120 |
+
sigma = NOISE_SCHEDULE[noise_level]
|
| 121 |
+
budget = BUDGET_SCHEDULE[noise_level]
|
| 122 |
+
n_vars = N_VARIABLES_SCHEDULE[noise_level]
|
| 123 |
+
|
| 124 |
+
self._world = generate_world(n_variables=n_vars, domain=domain, seed=seed)
|
| 125 |
+
self._tracker = InfoGainTracker()
|
| 126 |
+
self._episode_id = episode_id or str(uuid4())
|
| 127 |
+
self._step_count = 0
|
| 128 |
+
self._budget_total = budget
|
| 129 |
+
self._budget_remaining = budget
|
| 130 |
+
self._done = False
|
| 131 |
+
self._history = []
|
| 132 |
+
self._cumulative_reward = 0.0
|
| 133 |
+
self._noise_level = noise_level
|
| 134 |
+
self._sigma = sigma
|
| 135 |
+
self._domain = domain
|
| 136 |
+
|
| 137 |
+
system_msg = (
|
| 138 |
+
f"New episode started. Domain: {domain.upper()}.\n"
|
| 139 |
+
f"You have {n_vars} unknown variables: {', '.join(self._world.variables)}.\n"
|
| 140 |
+
f"Budget: {budget} experiment steps.\n"
|
| 141 |
+
f"Run experiments to discover the hidden causal rules, then SUBMIT your hypothesis.\n"
|
| 142 |
+
f"Noise level: {noise_level.value}.\n\n"
|
| 143 |
+
f"Available experiment types:\n"
|
| 144 |
+
f" INTERVENTION -- set one variable to a value, observe another\n"
|
| 145 |
+
f" CORRELATION -- sweep one variable across a range, observe another\n"
|
| 146 |
+
f" COUNTERFACTUAL-- ask 'what if variable changes by delta?'\n"
|
| 147 |
+
f" PASSIVE -- observe one variable in its default state\n"
|
| 148 |
+
f" SUBMIT -- submit your hypothesis (ends episode)"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
return HypLabObservation(
|
| 152 |
+
system_message=system_msg,
|
| 153 |
+
available_variables=self._world.variables,
|
| 154 |
+
budget_remaining=self._budget_remaining,
|
| 155 |
+
done=False,
|
| 156 |
+
reward=0.0,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
def step(
|
| 160 |
+
self,
|
| 161 |
+
action: HypLabAction,
|
| 162 |
+
timeout_s: Optional[float] = None,
|
| 163 |
+
**kwargs: Any,
|
| 164 |
+
) -> HypLabObservation:
|
| 165 |
+
if self._world is None:
|
| 166 |
+
return HypLabObservation(
|
| 167 |
+
system_message="Error: No active episode. Call reset() before step().",
|
| 168 |
+
done=True,
|
| 169 |
+
reward=-1.0,
|
| 170 |
+
)
|
| 171 |
+
if self._done:
|
| 172 |
+
return HypLabObservation(
|
| 173 |
+
system_message="Error: Episode is already done. Call reset() to start a new episode.",
|
| 174 |
+
available_variables=self._world.variables,
|
| 175 |
+
budget_remaining=self._budget_remaining,
|
| 176 |
+
done=True,
|
| 177 |
+
reward=0.0,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
self._step_count += 1
|
| 181 |
+
|
| 182 |
+
if action.action_type == ActionType.EXPERIMENT:
|
| 183 |
+
return self._handle_experiment(action)
|
| 184 |
+
elif action.action_type == ActionType.SUBMIT:
|
| 185 |
+
return self._handle_submit(action)
|
| 186 |
+
else:
|
| 187 |
+
return self._error_obs(
|
| 188 |
+
f"Unknown action_type: {action.action_type}. Use 'experiment' or 'submit'.",
|
| 189 |
+
deduct_budget=True,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
@property
|
| 193 |
+
def state(self) -> HypLabState:
|
| 194 |
+
return HypLabState(
|
| 195 |
+
episode_id=self._episode_id,
|
| 196 |
+
step_count=self._step_count,
|
| 197 |
+
budget_total=self._budget_total,
|
| 198 |
+
budget_remaining=self._budget_remaining,
|
| 199 |
+
noise_level=self._noise_level,
|
| 200 |
+
noise_sigma=self._sigma,
|
| 201 |
+
domain=self._domain,
|
| 202 |
+
n_variables=len(self._world.variables) if self._world else 0,
|
| 203 |
+
experiment_history=self._history,
|
| 204 |
+
cumulative_info_gain=self._tracker.cumulative_gain if self._tracker else 0.0,
|
| 205 |
+
redundant_experiment_count=self._tracker.redundant_count if self._tracker else 0,
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
def _handle_experiment(self, action: HypLabAction) -> HypLabObservation:
|
| 209 |
+
world = self._world
|
| 210 |
+
sigma = self._sigma
|
| 211 |
+
tracker = self._tracker
|
| 212 |
+
|
| 213 |
+
cause = action.control_variable or ""
|
| 214 |
+
effect = action.target_variable or ""
|
| 215 |
+
all_vars = world.variables
|
| 216 |
+
|
| 217 |
+
if cause not in all_vars:
|
| 218 |
+
return self._error_obs(
|
| 219 |
+
f"Unknown control variable '{cause}'. Available: {all_vars}",
|
| 220 |
+
deduct_budget=True,
|
| 221 |
+
)
|
| 222 |
+
if effect not in all_vars:
|
| 223 |
+
return self._error_obs(
|
| 224 |
+
f"Unknown target variable '{effect}'. Available: {all_vars}",
|
| 225 |
+
deduct_budget=True,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
exp_type = action.experiment_type or ExperimentType.INTERVENTION
|
| 229 |
+
result_value = None
|
| 230 |
+
|
| 231 |
+
if exp_type == ExperimentType.INTERVENTION:
|
| 232 |
+
val = action.control_value if action.control_value is not None else 5.0
|
| 233 |
+
result_value = world.query_intervention(cause, val, effect, sigma)
|
| 234 |
+
result_str = f"{effect} = {result_value:.4f} (sigma={sigma}, set {cause}={val})"
|
| 235 |
+
|
| 236 |
+
elif exp_type == ExperimentType.CORRELATION:
|
| 237 |
+
cr = action.control_range or [1.0, 10.0, 5.0]
|
| 238 |
+
pairs = world.query_correlation(cause, cr, effect, sigma)
|
| 239 |
+
result_value = pairs
|
| 240 |
+
result_str = (
|
| 241 |
+
f"Correlation sweep {cause} -> {effect}:\n"
|
| 242 |
+
+ "\n".join(f" {cause}={x:.2f} -> {effect}={y:.4f}" for x, y in pairs)
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
elif exp_type == ExperimentType.COUNTERFACTUAL:
|
| 246 |
+
delta = action.control_value or 1.0
|
| 247 |
+
cf = world.query_counterfactual(cause, delta, effect, sigma)
|
| 248 |
+
result_value = cf
|
| 249 |
+
result_str = (
|
| 250 |
+
f"Counterfactual: if {cause} changes by {delta:+.2f}:\n"
|
| 251 |
+
f" Baseline: {cause}={cf['baseline_x']:.2f} -> {effect}={cf['baseline_y_noisy']:.4f}\n"
|
| 252 |
+
f" After: {cause}={cf['counterfactual_x']:.2f} -> {effect}={cf['counterfactual_y_noisy']:.4f}\n"
|
| 253 |
+
f" Direction: {effect} {cf['direction']}"
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
elif exp_type == ExperimentType.PASSIVE:
|
| 257 |
+
result_value = world.query_passive(effect, sigma)
|
| 258 |
+
result_str = f"Passive observation: {effect} = {result_value:.4f} (sigma={sigma})"
|
| 259 |
+
|
| 260 |
+
else:
|
| 261 |
+
return self._error_obs(f"Unknown experiment type: {exp_type}")
|
| 262 |
+
|
| 263 |
+
info_gain, is_redundant = tracker.record_and_score(
|
| 264 |
+
cause, effect, exp_type.value, result_value
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
self._budget_remaining -= 1
|
| 268 |
+
budget_done = self._budget_remaining <= 0
|
| 269 |
+
self._cumulative_reward += info_gain
|
| 270 |
+
|
| 271 |
+
self._history.append({
|
| 272 |
+
"step": self._step_count,
|
| 273 |
+
"exp_type": exp_type.value,
|
| 274 |
+
"cause": cause,
|
| 275 |
+
"effect": effect,
|
| 276 |
+
"reward": round(info_gain, 4),
|
| 277 |
+
"redundant": is_redundant,
|
| 278 |
+
})
|
| 279 |
+
|
| 280 |
+
msg = f"[Step {self._step_count}] {result_str}"
|
| 281 |
+
if is_redundant:
|
| 282 |
+
msg += "\nRedundant experiment -- reward penalty applied."
|
| 283 |
+
if budget_done:
|
| 284 |
+
msg += "\nBudget exhausted. Submit your hypothesis now."
|
| 285 |
+
self._done = True
|
| 286 |
+
|
| 287 |
+
return HypLabObservation(
|
| 288 |
+
system_message=msg,
|
| 289 |
+
available_variables=world.variables,
|
| 290 |
+
budget_remaining=self._budget_remaining,
|
| 291 |
+
experiment_type_run=exp_type,
|
| 292 |
+
control_variable_used=cause,
|
| 293 |
+
control_value_used=(
|
| 294 |
+
action.control_value
|
| 295 |
+
if exp_type != ExperimentType.CORRELATION
|
| 296 |
+
else action.control_range
|
| 297 |
+
),
|
| 298 |
+
target_variable_observed=effect,
|
| 299 |
+
result_value=result_value,
|
| 300 |
+
noise_sigma=sigma,
|
| 301 |
+
is_redundant=is_redundant,
|
| 302 |
+
info_gain_reward=round(info_gain, 4),
|
| 303 |
+
reward=info_gain,
|
| 304 |
+
done=self._done,
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
def _handle_submit(self, action: HypLabAction) -> HypLabObservation:
|
| 308 |
+
self._done = True
|
| 309 |
+
|
| 310 |
+
rubric: RubricResult = score_hypothesis(
|
| 311 |
+
hypothesis_text=action.hypothesis_text or "",
|
| 312 |
+
hypothesis_equations=action.hypothesis_equations,
|
| 313 |
+
confidence=action.confidence,
|
| 314 |
+
world=self._world,
|
| 315 |
+
budget_remaining=self._budget_remaining,
|
| 316 |
+
budget_total=self._budget_total,
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
total_reward = rubric.total
|
| 320 |
+
self._cumulative_reward += total_reward
|
| 321 |
+
|
| 322 |
+
msg = (
|
| 323 |
+
f"[Episode End -- Step {self._step_count}]\n"
|
| 324 |
+
f"Hypothesis received. Evaluating against ground truth...\n\n"
|
| 325 |
+
f"RUBRIC BREAKDOWN:\n"
|
| 326 |
+
f" Accuracy score: {rubric.accuracy_score:+.4f}\n"
|
| 327 |
+
f" Precision bonus: {rubric.precision_bonus:+.4f}\n"
|
| 328 |
+
f" Calibration score: {rubric.calibration_score:+.4f}\n"
|
| 329 |
+
f" Efficiency bonus: {rubric.efficiency_bonus:+.4f}\n"
|
| 330 |
+
f" Contradiction penalty: {rubric.contradiction_penalty:+.4f}\n"
|
| 331 |
+
f" ────────────────────────────\n"
|
| 332 |
+
f" TOTAL EPISODE REWARD: {rubric.total:+.4f}\n\n"
|
| 333 |
+
f"FEEDBACK: {rubric.feedback}\n\n"
|
| 334 |
+
f"GROUND TRUTH:\n{rubric.ground_truth}"
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
return HypLabObservation(
|
| 338 |
+
system_message=msg,
|
| 339 |
+
available_variables=self._world.variables,
|
| 340 |
+
budget_remaining=self._budget_remaining,
|
| 341 |
+
accuracy_score=rubric.accuracy_score,
|
| 342 |
+
precision_bonus=rubric.precision_bonus,
|
| 343 |
+
calibration_score=rubric.calibration_score,
|
| 344 |
+
efficiency_bonus=rubric.efficiency_bonus,
|
| 345 |
+
contradiction_penalty=rubric.contradiction_penalty,
|
| 346 |
+
total_episode_reward=rubric.total,
|
| 347 |
+
ground_truth_revealed=rubric.ground_truth,
|
| 348 |
+
reward=total_reward,
|
| 349 |
+
done=True,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def _error_obs(
|
| 353 |
+
self, msg: str, deduct_budget: bool = False
|
| 354 |
+
) -> HypLabObservation:
|
| 355 |
+
if deduct_budget:
|
| 356 |
+
self._budget_remaining -= 1
|
| 357 |
+
return HypLabObservation(
|
| 358 |
+
system_message=f"Error: {msg}",
|
| 359 |
+
available_variables=self._world.variables if self._world else [],
|
| 360 |
+
budget_remaining=self._budget_remaining,
|
| 361 |
+
reward=-0.05,
|
| 362 |
+
done=False,
|
| 363 |
+
)
|
server/rubric.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
server/rubric.py -- Multi-component reward rubric for the Hypothesis Lab.
|
| 3 |
+
|
| 4 |
+
Components:
|
| 5 |
+
1. accuracy_score (0.0-1.0) -- how close is the hypothesis to ground truth
|
| 6 |
+
2. precision_bonus (+0.10) -- hypothesis contains quantitative claims
|
| 7 |
+
3. calibration_score (0.0-0.20) -- expressed confidence matches accuracy
|
| 8 |
+
4. efficiency_bonus (+0.15) -- submitted early with high accuracy
|
| 9 |
+
5. contradiction_penalty (-0.50) -- hypothesis contradicts hard constraints
|
| 10 |
+
|
| 11 |
+
Per-step info-gain scoring is handled by InfoGainTracker.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
import math
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from typing import Any, Optional
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
from .causal_world import CausalWorld
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class RubricResult:
|
| 28 |
+
"""Full rubric breakdown returned when a hypothesis is scored."""
|
| 29 |
+
|
| 30 |
+
accuracy_score: float = 0.0
|
| 31 |
+
precision_bonus: float = 0.0
|
| 32 |
+
calibration_score: float = 0.0
|
| 33 |
+
efficiency_bonus: float = 0.0
|
| 34 |
+
contradiction_penalty: float = 0.0
|
| 35 |
+
feedback: str = ""
|
| 36 |
+
ground_truth: str = ""
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def total(self) -> float:
|
| 40 |
+
return (
|
| 41 |
+
self.accuracy_score
|
| 42 |
+
+ self.precision_bonus
|
| 43 |
+
+ self.calibration_score
|
| 44 |
+
+ self.efficiency_bonus
|
| 45 |
+
+ self.contradiction_penalty
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def to_dict(self) -> dict[str, float]:
|
| 49 |
+
return {
|
| 50 |
+
"accuracy_score": round(self.accuracy_score, 4),
|
| 51 |
+
"precision_bonus": round(self.precision_bonus, 4),
|
| 52 |
+
"calibration_score": round(self.calibration_score, 4),
|
| 53 |
+
"efficiency_bonus": round(self.efficiency_bonus, 4),
|
| 54 |
+
"contradiction_penalty": round(self.contradiction_penalty, 4),
|
| 55 |
+
"total": round(self.total, 4),
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class InfoGainTracker:
|
| 60 |
+
"""
|
| 61 |
+
Tracks experiment history and computes per-step information gain rewards.
|
| 62 |
+
Also detects redundant experiments.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
def __init__(self) -> None:
|
| 66 |
+
self._edge_counts: dict[tuple[str, str], int] = {}
|
| 67 |
+
self._edge_types: dict[tuple[str, str], set[str]] = {}
|
| 68 |
+
self.cumulative_gain: float = 0.0
|
| 69 |
+
self.redundant_count: int = 0
|
| 70 |
+
|
| 71 |
+
def record_and_score(
|
| 72 |
+
self,
|
| 73 |
+
cause: str,
|
| 74 |
+
effect: str,
|
| 75 |
+
exp_type: str,
|
| 76 |
+
result_value: Any,
|
| 77 |
+
) -> tuple[float, bool]:
|
| 78 |
+
"""
|
| 79 |
+
Record an experiment and return (reward, is_redundant).
|
| 80 |
+
|
| 81 |
+
Reward schedule:
|
| 82 |
+
- First observation of an edge: +0.20
|
| 83 |
+
- Second (different exp type = triangulation bonus): +0.25
|
| 84 |
+
- Second (same type): +0.12
|
| 85 |
+
- Third+: -0.10 (redundant penalty)
|
| 86 |
+
"""
|
| 87 |
+
key = (cause, effect)
|
| 88 |
+
prior = self._edge_counts.get(key, 0)
|
| 89 |
+
prior_types = set(self._edge_types.get(key, set()))
|
| 90 |
+
|
| 91 |
+
self._edge_counts[key] = prior + 1
|
| 92 |
+
if key not in self._edge_types:
|
| 93 |
+
self._edge_types[key] = set()
|
| 94 |
+
self._edge_types[key].add(exp_type)
|
| 95 |
+
|
| 96 |
+
if prior == 0:
|
| 97 |
+
reward = 0.20
|
| 98 |
+
elif prior == 1:
|
| 99 |
+
triangulation = exp_type not in prior_types
|
| 100 |
+
reward = 0.25 if triangulation else 0.12
|
| 101 |
+
elif prior == 2:
|
| 102 |
+
reward = 0.05
|
| 103 |
+
else:
|
| 104 |
+
reward = -0.10
|
| 105 |
+
self.redundant_count += 1
|
| 106 |
+
|
| 107 |
+
is_redundant = prior >= 3
|
| 108 |
+
if is_redundant:
|
| 109 |
+
reward = -0.10
|
| 110 |
+
self.redundant_count += 1
|
| 111 |
+
|
| 112 |
+
self.cumulative_gain += max(reward, 0.0)
|
| 113 |
+
return round(reward, 4), is_redundant
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
HARD_CONSTRAINTS = [
|
| 117 |
+
(r"all variables.*independent", "Claiming all variables are independent contradicts the experimental setup"),
|
| 118 |
+
(r"no.*relationship|no.*causal", "Claiming no relationships exist contradicts the experimental setup"),
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
_RULE_KEYWORDS: dict[str, list[str]] = {
|
| 123 |
+
"linear": [
|
| 124 |
+
"linear", "proportional", "slope", "times", "multiply",
|
| 125 |
+
"increases", "decreases",
|
| 126 |
+
],
|
| 127 |
+
"threshold": [
|
| 128 |
+
"threshold", "above", "below", "greater", "less",
|
| 129 |
+
"if", "when", "switch", "cutoff",
|
| 130 |
+
],
|
| 131 |
+
"inverse": ["inverse", "inversely", "reciprocal", "divided", "1/"],
|
| 132 |
+
"quadratic": [
|
| 133 |
+
"quadratic", "squared", "parabol", "x^2", "x²", "nonlinear",
|
| 134 |
+
"curve", "polynomial",
|
| 135 |
+
],
|
| 136 |
+
"exponential": [
|
| 137 |
+
"exponential", "exp(", "growth", "decay", "e^", "geometric",
|
| 138 |
+
],
|
| 139 |
+
"logarithmic": [
|
| 140 |
+
"logarithm", "log(", "ln(", "log ", "diminishing returns",
|
| 141 |
+
],
|
| 142 |
+
"saturating": [
|
| 143 |
+
"saturating", "saturat", "michaelis", "plateau", "asymptote",
|
| 144 |
+
"levels off", "diminishing", "vmax",
|
| 145 |
+
],
|
| 146 |
+
"piecewise_linear": [
|
| 147 |
+
"piecewise", "breakpoint", "knot", "changes slope",
|
| 148 |
+
"two-segment", "regime change", "kink",
|
| 149 |
+
],
|
| 150 |
+
"additive": [
|
| 151 |
+
"additive", "sum", "combines", "both contribute", "joint",
|
| 152 |
+
],
|
| 153 |
+
"multiplicative": [
|
| 154 |
+
"multiplicative", "product", "multiply", "synerg", "interaction",
|
| 155 |
+
],
|
| 156 |
+
"min": ["minimum", "bottleneck", "limiting factor", "min("],
|
| 157 |
+
"max": ["maximum", "dominant", "max("],
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _accuracy_score(hypothesis: str, world: CausalWorld) -> float:
|
| 162 |
+
"""Score how well the hypothesis captures the ground truth rules."""
|
| 163 |
+
if not hypothesis.strip():
|
| 164 |
+
return 0.0
|
| 165 |
+
|
| 166 |
+
text = hypothesis.lower()
|
| 167 |
+
all_scorable = list(world.rules)
|
| 168 |
+
|
| 169 |
+
total_items = len(all_scorable) + len(world.interactions)
|
| 170 |
+
if total_items == 0:
|
| 171 |
+
return 0.5
|
| 172 |
+
|
| 173 |
+
hits = 0.0
|
| 174 |
+
|
| 175 |
+
for rule in all_scorable:
|
| 176 |
+
cause_l = rule.cause.lower()
|
| 177 |
+
effect_l = rule.effect.lower()
|
| 178 |
+
|
| 179 |
+
has_cause = cause_l in text or cause_l[:4] in text
|
| 180 |
+
has_effect = effect_l in text or effect_l[:4] in text
|
| 181 |
+
if not (has_cause and has_effect):
|
| 182 |
+
continue
|
| 183 |
+
|
| 184 |
+
hits += 0.4
|
| 185 |
+
|
| 186 |
+
keywords = _RULE_KEYWORDS.get(rule.rule_type, [])
|
| 187 |
+
if any(w in text for w in keywords):
|
| 188 |
+
hits += 0.3
|
| 189 |
+
|
| 190 |
+
key_param = _key_param_for_rule(rule)
|
| 191 |
+
if key_param is not None and str(round(abs(key_param), 1)) in hypothesis:
|
| 192 |
+
hits += 0.3
|
| 193 |
+
|
| 194 |
+
for inter in world.interactions:
|
| 195 |
+
c1_l = inter.cause1.lower()
|
| 196 |
+
c2_l = inter.cause2.lower()
|
| 197 |
+
eff_l = inter.effect.lower()
|
| 198 |
+
|
| 199 |
+
found_c1 = c1_l in text or c1_l[:4] in text
|
| 200 |
+
found_c2 = c2_l in text or c2_l[:4] in text
|
| 201 |
+
found_eff = eff_l in text or eff_l[:4] in text
|
| 202 |
+
|
| 203 |
+
if found_eff and (found_c1 or found_c2):
|
| 204 |
+
hits += 0.3
|
| 205 |
+
if found_eff and found_c1 and found_c2:
|
| 206 |
+
hits += 0.2
|
| 207 |
+
|
| 208 |
+
keywords = _RULE_KEYWORDS.get(inter.interaction_type, [])
|
| 209 |
+
if any(w in text for w in keywords):
|
| 210 |
+
hits += 0.5
|
| 211 |
+
|
| 212 |
+
max_possible = total_items * 1.0
|
| 213 |
+
return min(hits / max_possible, 1.0) if max_possible > 0 else 0.0
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _key_param_for_rule(rule) -> Optional[float]:
|
| 217 |
+
"""Return the most important parameter for a rule type, for matching."""
|
| 218 |
+
rt = rule.rule_type
|
| 219 |
+
p = rule.params
|
| 220 |
+
if rt == "linear":
|
| 221 |
+
return p.get("a")
|
| 222 |
+
elif rt == "threshold":
|
| 223 |
+
return p.get("threshold")
|
| 224 |
+
elif rt == "inverse":
|
| 225 |
+
return p.get("a")
|
| 226 |
+
elif rt == "quadratic":
|
| 227 |
+
return p.get("a")
|
| 228 |
+
elif rt == "exponential":
|
| 229 |
+
return p.get("k")
|
| 230 |
+
elif rt == "logarithmic":
|
| 231 |
+
return p.get("a")
|
| 232 |
+
elif rt == "saturating":
|
| 233 |
+
return p.get("v_max")
|
| 234 |
+
elif rt == "piecewise_linear":
|
| 235 |
+
return p.get("knot")
|
| 236 |
+
return None
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _precision_bonus(text: str) -> float:
|
| 240 |
+
"""Does the hypothesis contain numerical values?"""
|
| 241 |
+
numbers = re.findall(r"-?\d+\.?\d*", text)
|
| 242 |
+
meaningful = [n for n in numbers if n not in ("0", "1")]
|
| 243 |
+
return 0.10 if len(meaningful) >= 2 else 0.0
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _calibration_score(expressed: Optional[float], actual: float) -> float:
|
| 247 |
+
"""Score based on |expressed_confidence - actual_accuracy|."""
|
| 248 |
+
if expressed is None:
|
| 249 |
+
return 0.0
|
| 250 |
+
error = abs(expressed - actual)
|
| 251 |
+
return max(0.0, 0.20 * (1.0 - error / 0.5))
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _constraint_penalty(text: str) -> float:
|
| 255 |
+
text_l = text.lower()
|
| 256 |
+
for pattern, _ in HARD_CONSTRAINTS:
|
| 257 |
+
if re.search(pattern, text_l):
|
| 258 |
+
return -0.50
|
| 259 |
+
return 0.0
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _build_feedback(result: RubricResult) -> str:
|
| 263 |
+
lines = []
|
| 264 |
+
if result.accuracy_score >= 0.75:
|
| 265 |
+
lines.append("Strong accuracy -- you identified most causal relationships.")
|
| 266 |
+
elif result.accuracy_score >= 0.40:
|
| 267 |
+
lines.append("Partial accuracy -- some relationships identified correctly.")
|
| 268 |
+
else:
|
| 269 |
+
lines.append("Low accuracy -- try running more diverse experiments.")
|
| 270 |
+
|
| 271 |
+
if result.precision_bonus > 0:
|
| 272 |
+
lines.append("Good precision -- quantitative claims detected.")
|
| 273 |
+
else:
|
| 274 |
+
lines.append("Tip: include numerical values (slopes, thresholds) for precision bonus.")
|
| 275 |
+
|
| 276 |
+
if result.efficiency_bonus > 0:
|
| 277 |
+
lines.append("Efficient submission -- well-timed.")
|
| 278 |
+
else:
|
| 279 |
+
lines.append("Tip: submit earlier when confident to earn efficiency bonus.")
|
| 280 |
+
|
| 281 |
+
if result.calibration_score >= 0.15:
|
| 282 |
+
lines.append("Well-calibrated confidence.")
|
| 283 |
+
elif result.calibration_score > 0:
|
| 284 |
+
lines.append("Confidence calibration could improve.")
|
| 285 |
+
|
| 286 |
+
if result.contradiction_penalty < 0:
|
| 287 |
+
lines.append("WARNING: hypothesis contradicts known physical constraints.")
|
| 288 |
+
|
| 289 |
+
return " ".join(lines)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def score_hypothesis(
|
| 293 |
+
hypothesis_text: str,
|
| 294 |
+
hypothesis_equations: Optional[list[str]],
|
| 295 |
+
confidence: Optional[float],
|
| 296 |
+
world: CausalWorld,
|
| 297 |
+
budget_remaining: int,
|
| 298 |
+
budget_total: int,
|
| 299 |
+
) -> RubricResult:
|
| 300 |
+
"""
|
| 301 |
+
Score a submitted hypothesis against the ground truth world.
|
| 302 |
+
|
| 303 |
+
Returns a RubricResult with all component scores, feedback text,
|
| 304 |
+
and the revealed ground truth.
|
| 305 |
+
"""
|
| 306 |
+
full_text = hypothesis_text or ""
|
| 307 |
+
if hypothesis_equations:
|
| 308 |
+
full_text += " " + " ".join(hypothesis_equations)
|
| 309 |
+
|
| 310 |
+
result = RubricResult()
|
| 311 |
+
|
| 312 |
+
result.accuracy_score = _accuracy_score(full_text, world)
|
| 313 |
+
result.precision_bonus = _precision_bonus(full_text)
|
| 314 |
+
result.calibration_score = _calibration_score(confidence, result.accuracy_score)
|
| 315 |
+
result.contradiction_penalty = _constraint_penalty(full_text)
|
| 316 |
+
|
| 317 |
+
ratio = budget_remaining / max(budget_total, 1)
|
| 318 |
+
if ratio >= 0.30 and result.accuracy_score >= 0.60:
|
| 319 |
+
result.efficiency_bonus = 0.15
|
| 320 |
+
elif ratio >= 0.15 and result.accuracy_score >= 0.40:
|
| 321 |
+
result.efficiency_bonus = 0.07
|
| 322 |
+
|
| 323 |
+
result.ground_truth = world.ground_truth_summary()
|
| 324 |
+
result.feedback = _build_feedback(result)
|
| 325 |
+
|
| 326 |
+
return result
|
tasks/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task definitions and graders for the Scientific Hypothesis Lab."""
|
| 2 |
+
|
| 3 |
+
from .task_easy import TASK_EASY, grade_easy
|
| 4 |
+
from .task_medium import TASK_MEDIUM, grade_medium
|
| 5 |
+
from .task_hard import TASK_HARD, grade_hard
|
| 6 |
+
|
| 7 |
+
ALL_TASKS = [TASK_EASY, TASK_MEDIUM, TASK_HARD]
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"TASK_EASY",
|
| 11 |
+
"TASK_MEDIUM",
|
| 12 |
+
"TASK_HARD",
|
| 13 |
+
"grade_easy",
|
| 14 |
+
"grade_medium",
|
| 15 |
+
"grade_hard",
|
| 16 |
+
"ALL_TASKS",
|
| 17 |
+
]
|
tasks/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (616 Bytes). View file
|
|
|
tasks/__pycache__/task_easy.cpython-311.pyc
ADDED
|
Binary file (2.29 kB). View file
|
|
|
tasks/__pycache__/task_hard.cpython-311.pyc
ADDED
|
Binary file (2.36 kB). View file
|
|
|
tasks/__pycache__/task_medium.cpython-311.pyc
ADDED
|
Binary file (2.04 kB). View file
|
|
|
tasks/task_easy.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tasks/task_easy.py -- Easy task: 2 variables, low noise.
|
| 3 |
+
|
| 4 |
+
The agent must discover a single causal relationship between two abstract
|
| 5 |
+
variables in a low-noise setting with a generous budget.
|
| 6 |
+
|
| 7 |
+
Grader returns 0.0-1.0 based on:
|
| 8 |
+
- Hypothesis accuracy (60%)
|
| 9 |
+
- Efficiency bonus (20%)
|
| 10 |
+
- Calibration (20%)
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
TASK_EASY = {
|
| 18 |
+
"id": "easy",
|
| 19 |
+
"name": "Easy -- Single-Edge Discovery",
|
| 20 |
+
"description": (
|
| 21 |
+
"Discover the causal relationship between two abstract variables. "
|
| 22 |
+
"Low noise (sigma=0.05), generous budget (12 steps)."
|
| 23 |
+
),
|
| 24 |
+
"difficulty": "easy",
|
| 25 |
+
"reset_kwargs": {
|
| 26 |
+
"noise_level": "low",
|
| 27 |
+
"domain": "system_alpha",
|
| 28 |
+
"seed": 42,
|
| 29 |
+
},
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def grade_easy(episode_result: dict[str, Any]) -> float:
|
| 34 |
+
"""
|
| 35 |
+
Grade an easy-task episode. Returns a score in [0.0, 1.0].
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
episode_result: Dict containing at minimum:
|
| 39 |
+
- accuracy_score (float): from the rubric
|
| 40 |
+
- efficiency_bonus (float): from the rubric
|
| 41 |
+
- calibration_score (float): from the rubric
|
| 42 |
+
- total_episode_reward (float): sum of all rubric components
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
Normalized score between 0.0 and 1.0.
|
| 46 |
+
"""
|
| 47 |
+
accuracy = episode_result.get("accuracy_score", 0.0)
|
| 48 |
+
efficiency = episode_result.get("efficiency_bonus", 0.0)
|
| 49 |
+
calibration = episode_result.get("calibration_score", 0.0)
|
| 50 |
+
|
| 51 |
+
raw = (
|
| 52 |
+
0.60 * min(accuracy, 1.0)
|
| 53 |
+
+ 0.20 * min(efficiency / 0.15, 1.0)
|
| 54 |
+
+ 0.20 * min(calibration / 0.20, 1.0)
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return round(max(0.0, min(1.0, raw)), 4)
|
tasks/task_hard.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tasks/task_hard.py -- Hard task: 4 variables, high noise, random domain.
|
| 3 |
+
|
| 4 |
+
The agent must discover a complex causal graph with high noise and a
|
| 5 |
+
tight budget. This is designed to challenge frontier models.
|
| 6 |
+
|
| 7 |
+
Grader returns 0.0-1.0 based on:
|
| 8 |
+
- Hypothesis accuracy (40%)
|
| 9 |
+
- Precision bonus (15%)
|
| 10 |
+
- Efficiency bonus (15%)
|
| 11 |
+
- Calibration (15%)
|
| 12 |
+
- Avoiding contradiction penalty (15%)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
TASK_HARD = {
|
| 20 |
+
"id": "hard",
|
| 21 |
+
"name": "Hard -- Complex Graph Under Noise",
|
| 22 |
+
"description": (
|
| 23 |
+
"Discover causal relationships among four variables under "
|
| 24 |
+
"high noise (sigma=0.50) with a tight budget (8 steps). "
|
| 25 |
+
"Requires strategic experiment design and careful reasoning."
|
| 26 |
+
),
|
| 27 |
+
"difficulty": "hard",
|
| 28 |
+
"reset_kwargs": {
|
| 29 |
+
"noise_level": "high",
|
| 30 |
+
"seed": 999,
|
| 31 |
+
},
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def grade_hard(episode_result: dict[str, Any]) -> float:
|
| 36 |
+
"""
|
| 37 |
+
Grade a hard-task episode. Returns a score in [0.0, 1.0].
|
| 38 |
+
"""
|
| 39 |
+
accuracy = episode_result.get("accuracy_score", 0.0)
|
| 40 |
+
precision = episode_result.get("precision_bonus", 0.0)
|
| 41 |
+
efficiency = episode_result.get("efficiency_bonus", 0.0)
|
| 42 |
+
calibration = episode_result.get("calibration_score", 0.0)
|
| 43 |
+
contradiction = episode_result.get("contradiction_penalty", 0.0)
|
| 44 |
+
|
| 45 |
+
no_contradiction = 1.0 if contradiction >= 0.0 else 0.0
|
| 46 |
+
|
| 47 |
+
raw = (
|
| 48 |
+
0.40 * min(accuracy, 1.0)
|
| 49 |
+
+ 0.15 * min(precision / 0.10, 1.0)
|
| 50 |
+
+ 0.15 * min(efficiency / 0.15, 1.0)
|
| 51 |
+
+ 0.15 * min(calibration / 0.20, 1.0)
|
| 52 |
+
+ 0.15 * no_contradiction
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
return round(max(0.0, min(1.0, raw)), 4)
|
tasks/task_medium.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tasks/task_medium.py -- Medium task: 3 variables, moderate noise, random domain.
|
| 3 |
+
|
| 4 |
+
The agent must discover multiple causal relationships with moderate noise
|
| 5 |
+
and a standard budget.
|
| 6 |
+
|
| 7 |
+
Grader returns 0.0-1.0 based on:
|
| 8 |
+
- Hypothesis accuracy (50%)
|
| 9 |
+
- Precision bonus (15%)
|
| 10 |
+
- Efficiency bonus (15%)
|
| 11 |
+
- Calibration (20%)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
TASK_MEDIUM = {
|
| 19 |
+
"id": "medium",
|
| 20 |
+
"name": "Medium -- Multi-Edge Discovery",
|
| 21 |
+
"description": (
|
| 22 |
+
"Discover causal relationships among three variables. "
|
| 23 |
+
"Moderate noise (sigma=0.20), standard budget (10 steps)."
|
| 24 |
+
),
|
| 25 |
+
"difficulty": "medium",
|
| 26 |
+
"reset_kwargs": {
|
| 27 |
+
"noise_level": "medium",
|
| 28 |
+
"seed": 123,
|
| 29 |
+
},
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def grade_medium(episode_result: dict[str, Any]) -> float:
|
| 34 |
+
"""
|
| 35 |
+
Grade a medium-task episode. Returns a score in [0.0, 1.0].
|
| 36 |
+
"""
|
| 37 |
+
accuracy = episode_result.get("accuracy_score", 0.0)
|
| 38 |
+
precision = episode_result.get("precision_bonus", 0.0)
|
| 39 |
+
efficiency = episode_result.get("efficiency_bonus", 0.0)
|
| 40 |
+
calibration = episode_result.get("calibration_score", 0.0)
|
| 41 |
+
|
| 42 |
+
raw = (
|
| 43 |
+
0.50 * min(accuracy, 1.0)
|
| 44 |
+
+ 0.15 * min(precision / 0.10, 1.0)
|
| 45 |
+
+ 0.15 * min(efficiency / 0.15, 1.0)
|
| 46 |
+
+ 0.20 * min(calibration / 0.20, 1.0)
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
return round(max(0.0, min(1.0, raw)), 4)
|
test_docker.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
test_docker.py -- Test the Hypothesis Lab environment using the OpenEnv client.
|
| 3 |
+
|
| 4 |
+
Run with: python test_docker.py
|
| 5 |
+
Requires: the Docker container running on localhost:8000
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
|
| 10 |
+
from client import HypothesisLabEnv
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def main():
|
| 14 |
+
async with HypothesisLabEnv(base_url="http://localhost:8000") as env:
|
| 15 |
+
|
| 16 |
+
# 1. Reset
|
| 17 |
+
result = await env.reset(noise_level="low", domain="system_alpha", seed=42)
|
| 18 |
+
obs = result.observation
|
| 19 |
+
print("=== Episode Started ===")
|
| 20 |
+
print(f"Message: {obs.system_message[:80]}...")
|
| 21 |
+
print(f"Variables: {obs.available_variables}")
|
| 22 |
+
print(f"Budget: {obs.budget_remaining}")
|
| 23 |
+
print()
|
| 24 |
+
|
| 25 |
+
v = obs.available_variables
|
| 26 |
+
cause, effect = v[0], v[1]
|
| 27 |
+
|
| 28 |
+
# 2. Intervention -- set one variable, observe the other
|
| 29 |
+
result = await env.run_intervention(cause, 5.0, effect)
|
| 30 |
+
obs = result.observation
|
| 31 |
+
print(f"[Intervention] Set {cause}=5.0 -> {effect}={obs.result_value:.4f}")
|
| 32 |
+
print(f" Info gain: {obs.info_gain_reward} Budget left: {obs.budget_remaining}")
|
| 33 |
+
print()
|
| 34 |
+
|
| 35 |
+
# 3. Try the reverse direction to check causality
|
| 36 |
+
result = await env.run_intervention(effect, 5.0, cause)
|
| 37 |
+
obs = result.observation
|
| 38 |
+
print(f"[Reverse] Set {effect}=5.0 -> {cause}={obs.result_value:.4f}")
|
| 39 |
+
print(f" Info gain: {obs.info_gain_reward} Budget left: {obs.budget_remaining}")
|
| 40 |
+
print()
|
| 41 |
+
|
| 42 |
+
# 4. Correlation sweep -- see the shape of the relationship
|
| 43 |
+
result = await env.run_correlation(cause, [0.5, 20.0, 8], effect)
|
| 44 |
+
obs = result.observation
|
| 45 |
+
print(f"[Correlation] Swept {cause} from 0.5 to 20.0:")
|
| 46 |
+
for x, y in obs.result_value:
|
| 47 |
+
print(f" {cause}={x:.1f} -> {effect}={y:.4f}")
|
| 48 |
+
print(f" Info gain: {obs.info_gain_reward} Budget left: {obs.budget_remaining}")
|
| 49 |
+
print()
|
| 50 |
+
|
| 51 |
+
# 5. Counterfactual -- what if cause changes by +3?
|
| 52 |
+
result = await env.run_counterfactual(cause, 3.0, effect)
|
| 53 |
+
obs = result.observation
|
| 54 |
+
cf = obs.result_value
|
| 55 |
+
print(f"[Counterfactual] If {cause} changes by +3.0:")
|
| 56 |
+
print(f" Baseline: {cause}={cf['baseline_x']:.2f} -> {effect}={cf['baseline_y_noisy']:.4f}")
|
| 57 |
+
print(f" After: {cause}={cf['counterfactual_x']:.2f} -> {effect}={cf['counterfactual_y_noisy']:.4f}")
|
| 58 |
+
print(f" Direction: {cf['direction']}")
|
| 59 |
+
print(f" Info gain: {obs.info_gain_reward} Budget left: {obs.budget_remaining}")
|
| 60 |
+
print()
|
| 61 |
+
|
| 62 |
+
# 6. Passive observation -- observe a variable without touching anything
|
| 63 |
+
result = await env.run_passive(effect)
|
| 64 |
+
obs = result.observation
|
| 65 |
+
print(f"[Passive] {effect} at rest = {obs.result_value:.4f}")
|
| 66 |
+
print(f" Info gain: {obs.info_gain_reward} Budget left: {obs.budget_remaining}")
|
| 67 |
+
print()
|
| 68 |
+
|
| 69 |
+
# 7. Submit hypothesis
|
| 70 |
+
result = await env.submit_hypothesis(
|
| 71 |
+
hypothesis_text=f"{effect} decays exponentially as {cause} increases.",
|
| 72 |
+
hypothesis_equations=[f"{effect} = 1.1 * exp(-0.16 * {cause})"],
|
| 73 |
+
confidence=0.65,
|
| 74 |
+
)
|
| 75 |
+
obs = result.observation
|
| 76 |
+
print("=== Episode Finished ===")
|
| 77 |
+
print(f"Accuracy: {obs.accuracy_score}")
|
| 78 |
+
print(f"Precision: {obs.precision_bonus}")
|
| 79 |
+
print(f"Calibration: {obs.calibration_score}")
|
| 80 |
+
print(f"Efficiency: {obs.efficiency_bonus}")
|
| 81 |
+
print(f"Contradiction: {obs.contradiction_penalty}")
|
| 82 |
+
print(f"TOTAL REWARD: {obs.total_episode_reward}")
|
| 83 |
+
print()
|
| 84 |
+
print(f"Ground truth:\n{obs.ground_truth_revealed}")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
asyncio.run(main())
|
test_local.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test the hypothesis-lab environment running locally on Docker (port 8000).
|
| 3 |
+
Uses the standard OpenEnv interface: env.reset() and env.step(action).
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import sys
|
| 7 |
+
sys.path.insert(0, "/Users/sbhimraj/Documents/Openenv/lab-experiment")
|
| 8 |
+
|
| 9 |
+
from client import HypothesisLabEnv
|
| 10 |
+
from models import HypLabAction, ActionType, ExperimentType
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def main():
|
| 14 |
+
print("=== Testing local hypothesis-lab environment ===\n")
|
| 15 |
+
|
| 16 |
+
async with HypothesisLabEnv(base_url="http://localhost:8000") as env:
|
| 17 |
+
|
| 18 |
+
# Reset episode
|
| 19 |
+
result = await env.reset(noise_level="low")
|
| 20 |
+
obs = result.observation
|
| 21 |
+
variables = obs.available_variables
|
| 22 |
+
print(f"[reset]")
|
| 23 |
+
print(f" variables: {variables}")
|
| 24 |
+
print(f" budget: {obs.budget_remaining}")
|
| 25 |
+
print(f" message: {obs.system_message[:120]}\n")
|
| 26 |
+
|
| 27 |
+
v1, v2 = variables[0], variables[1]
|
| 28 |
+
|
| 29 |
+
# Step 1 — intervention experiment
|
| 30 |
+
action = HypLabAction(
|
| 31 |
+
action_type=ActionType.EXPERIMENT,
|
| 32 |
+
experiment_type=ExperimentType.INTERVENTION,
|
| 33 |
+
control_variable=v1,
|
| 34 |
+
control_value=2.5,
|
| 35 |
+
target_variable=v2,
|
| 36 |
+
)
|
| 37 |
+
result = await env.step(action)
|
| 38 |
+
obs = result.observation
|
| 39 |
+
print(f"[step 1: intervention]")
|
| 40 |
+
print(f" {v1}=2.5 → {v2} = {obs.result_value}")
|
| 41 |
+
print(f" info_gain: {obs.info_gain_reward}")
|
| 42 |
+
print(f" budget left: {obs.budget_remaining}")
|
| 43 |
+
print(f" done: {result.done}\n")
|
| 44 |
+
|
| 45 |
+
# Step 2 — correlation experiment
|
| 46 |
+
action = HypLabAction(
|
| 47 |
+
action_type=ActionType.EXPERIMENT,
|
| 48 |
+
experiment_type=ExperimentType.CORRELATION,
|
| 49 |
+
control_variable=v1,
|
| 50 |
+
control_range=[0.0, 1.0, 2.0, 3.0, 4.0],
|
| 51 |
+
target_variable=v2,
|
| 52 |
+
)
|
| 53 |
+
result = await env.step(action)
|
| 54 |
+
obs = result.observation
|
| 55 |
+
print(f"[step 2: correlation]")
|
| 56 |
+
print(f" {v1} swept → {v2} = {obs.result_value}")
|
| 57 |
+
print(f" info_gain: {obs.info_gain_reward}")
|
| 58 |
+
print(f" budget left: {obs.budget_remaining}")
|
| 59 |
+
print(f" done: {result.done}\n")
|
| 60 |
+
|
| 61 |
+
# Step 3 — submit hypothesis
|
| 62 |
+
action = HypLabAction(
|
| 63 |
+
action_type=ActionType.SUBMIT,
|
| 64 |
+
hypothesis_text=f"{v2} increases linearly with {v1} by approximately 2.5 units",
|
| 65 |
+
confidence=0.7,
|
| 66 |
+
)
|
| 67 |
+
result = await env.step(action)
|
| 68 |
+
obs = result.observation
|
| 69 |
+
print(f"[step 3: submit]")
|
| 70 |
+
print(f" reward: {result.reward}")
|
| 71 |
+
print(f" accuracy_score: {obs.accuracy_score}")
|
| 72 |
+
print(f" precision_bonus: {obs.precision_bonus}")
|
| 73 |
+
print(f" calibration_score: {obs.calibration_score}")
|
| 74 |
+
print(f" efficiency_bonus: {obs.efficiency_bonus}")
|
| 75 |
+
print(f" done: {result.done}")
|
| 76 |
+
if obs.ground_truth_revealed:
|
| 77 |
+
print(f" ground_truth: {obs.ground_truth_revealed[:300]}")
|
| 78 |
+
|
| 79 |
+
print("\n=== Done — environment is working correctly ===")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
asyncio.run(main())
|
tests/__init__.py
ADDED
|
File without changes
|
tests/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (168 Bytes). View file
|
|
|
tests/__pycache__/test_environment.cpython-311-pytest-9.0.2.pyc
ADDED
|
Binary file (75.3 kB). View file
|
|
|
tests/__pycache__/test_environment.cpython-311.pyc
ADDED
|
Binary file (19.4 kB). View file
|
|
|
tests/test_environment.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tests/test_environment.py -- Unit + integration tests for HypothesisLab.
|
| 3 |
+
|
| 4 |
+
Run with: pytest tests/ -v
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import math
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from models import ActionType, ExperimentType, HypLabAction, NoiseLevelTag
|
| 11 |
+
from server.causal_world import generate_world, CausalWorld, CausalRule, InteractionRule
|
| 12 |
+
from server.rubric import InfoGainTracker, score_hypothesis
|
| 13 |
+
from server.hypothesis_lab_environment import HypothesisLabEnvironment
|
| 14 |
+
from tasks.task_easy import grade_easy
|
| 15 |
+
from tasks.task_medium import grade_medium
|
| 16 |
+
from tasks.task_hard import grade_hard
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TestCausalWorld:
|
| 20 |
+
|
| 21 |
+
def test_generate_world_returns_correct_n_variables(self):
|
| 22 |
+
for n in [2, 3, 4]:
|
| 23 |
+
world = generate_world(n_variables=n, domain="system_alpha", seed=42)
|
| 24 |
+
assert len(world.variables) == n
|
| 25 |
+
|
| 26 |
+
def test_all_domains_generate_without_error(self):
|
| 27 |
+
for domain in ["system_alpha", "system_beta", "system_gamma", "system_delta"]:
|
| 28 |
+
world = generate_world(n_variables=3, domain=domain, seed=0)
|
| 29 |
+
assert world.domain == domain
|
| 30 |
+
assert len(world.rules) >= 1
|
| 31 |
+
|
| 32 |
+
def test_linear_rule_evaluation(self):
|
| 33 |
+
rule = CausalRule(
|
| 34 |
+
cause="X", effect="Y",
|
| 35 |
+
rule_type="linear",
|
| 36 |
+
params={"a": 2.0, "b": 3.0},
|
| 37 |
+
description="Y = 2.0 * X + 3.0",
|
| 38 |
+
)
|
| 39 |
+
assert rule.evaluate(0) == pytest.approx(3.0)
|
| 40 |
+
assert rule.evaluate(5) == pytest.approx(13.0)
|
| 41 |
+
|
| 42 |
+
def test_inverse_rule_avoids_division_by_zero(self):
|
| 43 |
+
rule = CausalRule(
|
| 44 |
+
cause="X", effect="Y",
|
| 45 |
+
rule_type="inverse",
|
| 46 |
+
params={"a": 10.0},
|
| 47 |
+
description="Y = 10 / X",
|
| 48 |
+
)
|
| 49 |
+
result = rule.evaluate(0)
|
| 50 |
+
assert math.isnan(result)
|
| 51 |
+
|
| 52 |
+
def test_intervention_with_noise_is_noisy(self):
|
| 53 |
+
world = generate_world(n_variables=2, domain="system_alpha", seed=1)
|
| 54 |
+
cause, effect = world.variables[0], world.variables[1]
|
| 55 |
+
results = [world.query_intervention(cause, 5.0, effect, sigma=0.5) for _ in range(20)]
|
| 56 |
+
unique = len(set(results))
|
| 57 |
+
assert unique > 1, "Noisy results should not be identical"
|
| 58 |
+
|
| 59 |
+
def test_correlation_returns_correct_n_points(self):
|
| 60 |
+
world = generate_world(n_variables=2, domain="system_beta", seed=2)
|
| 61 |
+
cause, effect = world.variables[0], world.variables[1]
|
| 62 |
+
pairs = world.query_correlation(cause, [1.0, 10.0, 5.0], effect, sigma=0.0)
|
| 63 |
+
assert len(pairs) == 5
|
| 64 |
+
|
| 65 |
+
def test_ground_truth_summary_contains_all_variables(self):
|
| 66 |
+
world = generate_world(n_variables=3, domain="system_gamma", seed=3)
|
| 67 |
+
summary = world.ground_truth_summary()
|
| 68 |
+
for v in world.variables:
|
| 69 |
+
assert v in summary
|
| 70 |
+
|
| 71 |
+
def test_seed_reproducibility(self):
|
| 72 |
+
world1 = generate_world(n_variables=3, domain="system_alpha", seed=99)
|
| 73 |
+
world2 = generate_world(n_variables=3, domain="system_alpha", seed=99)
|
| 74 |
+
assert world1.variables == world2.variables
|
| 75 |
+
assert world1.rules[0].rule_type == world2.rules[0].rule_type
|
| 76 |
+
|
| 77 |
+
def test_quadratic_rule_evaluation(self):
|
| 78 |
+
rule = CausalRule(
|
| 79 |
+
cause="X", effect="Y",
|
| 80 |
+
rule_type="quadratic",
|
| 81 |
+
params={"a": 0.5, "b": 1.0, "c": 2.0},
|
| 82 |
+
description="Y = 0.5*X^2 + 1.0*X + 2.0",
|
| 83 |
+
)
|
| 84 |
+
assert rule.evaluate(0) == pytest.approx(2.0)
|
| 85 |
+
assert rule.evaluate(4) == pytest.approx(0.5*16 + 4 + 2)
|
| 86 |
+
|
| 87 |
+
def test_exponential_rule_evaluation(self):
|
| 88 |
+
rule = CausalRule(
|
| 89 |
+
cause="X", effect="Y",
|
| 90 |
+
rule_type="exponential",
|
| 91 |
+
params={"a": 2.0, "k": 0.0},
|
| 92 |
+
description="Y = 2 * exp(0 * X)",
|
| 93 |
+
)
|
| 94 |
+
assert rule.evaluate(5) == pytest.approx(2.0)
|
| 95 |
+
|
| 96 |
+
def test_logarithmic_rule_nan_for_zero(self):
|
| 97 |
+
rule = CausalRule(
|
| 98 |
+
cause="X", effect="Y",
|
| 99 |
+
rule_type="logarithmic",
|
| 100 |
+
params={"a": 3.0, "b": 0.0},
|
| 101 |
+
)
|
| 102 |
+
assert math.isnan(rule.evaluate(0))
|
| 103 |
+
|
| 104 |
+
def test_saturating_rule_approaches_vmax(self):
|
| 105 |
+
rule = CausalRule(
|
| 106 |
+
cause="X", effect="Y",
|
| 107 |
+
rule_type="saturating",
|
| 108 |
+
params={"v_max": 10.0, "k_m": 2.0},
|
| 109 |
+
)
|
| 110 |
+
assert rule.evaluate(1000) == pytest.approx(10.0, abs=0.1)
|
| 111 |
+
assert rule.evaluate(2.0) == pytest.approx(5.0, abs=0.01)
|
| 112 |
+
|
| 113 |
+
def test_piecewise_linear_changes_slope(self):
|
| 114 |
+
rule = CausalRule(
|
| 115 |
+
cause="X", effect="Y",
|
| 116 |
+
rule_type="piecewise_linear",
|
| 117 |
+
params={"knot": 5.0, "a1": 2.0, "a2": -1.0, "b": 0.0},
|
| 118 |
+
)
|
| 119 |
+
assert rule.evaluate(3) == pytest.approx(6.0)
|
| 120 |
+
assert rule.evaluate(7) == pytest.approx(10.0 + (-1.0) * 2)
|
| 121 |
+
|
| 122 |
+
def test_interaction_rule_multiplicative(self):
|
| 123 |
+
inter = InteractionRule(
|
| 124 |
+
cause1="X", cause2="Y", effect="Z",
|
| 125 |
+
interaction_type="multiplicative",
|
| 126 |
+
params={"a": 0.5},
|
| 127 |
+
)
|
| 128 |
+
assert inter.evaluate(4.0, 6.0) == pytest.approx(12.0)
|
| 129 |
+
|
| 130 |
+
def test_interaction_rule_min(self):
|
| 131 |
+
inter = InteractionRule(
|
| 132 |
+
cause1="X", cause2="Y", effect="Z",
|
| 133 |
+
interaction_type="min",
|
| 134 |
+
)
|
| 135 |
+
assert inter.evaluate(3.0, 7.0) == pytest.approx(3.0)
|
| 136 |
+
|
| 137 |
+
def test_diverse_rule_types_generated_over_many_seeds(self):
|
| 138 |
+
"""Over many seeds we should see more than 3 distinct rule types."""
|
| 139 |
+
types_seen: set[str] = set()
|
| 140 |
+
for seed in range(100):
|
| 141 |
+
world = generate_world(n_variables=3, seed=seed)
|
| 142 |
+
for rule in world.rules:
|
| 143 |
+
types_seen.add(rule.rule_type)
|
| 144 |
+
assert len(types_seen) >= 5, f"Only saw {types_seen}"
|
| 145 |
+
|
| 146 |
+
def test_delta_domain_works(self):
|
| 147 |
+
world = generate_world(n_variables=3, domain="system_delta", seed=42)
|
| 148 |
+
assert world.domain == "system_delta"
|
| 149 |
+
assert len(world.rules) >= 1
|
| 150 |
+
|
| 151 |
+
def test_variable_names_are_abstract(self):
|
| 152 |
+
"""Variables should NOT be real-world names that give LLMs prior knowledge."""
|
| 153 |
+
real_world_names = {
|
| 154 |
+
"temperature", "pressure", "volume", "density", "entropy",
|
| 155 |
+
"price", "demand", "supply", "wage", "inflation",
|
| 156 |
+
"genea", "proteinb", "enzymec", "concentration", "ph",
|
| 157 |
+
}
|
| 158 |
+
for seed in range(50):
|
| 159 |
+
world = generate_world(n_variables=4, seed=seed)
|
| 160 |
+
for v in world.variables:
|
| 161 |
+
assert v.lower() not in real_world_names, (
|
| 162 |
+
f"Variable '{v}' is a real-world name that gives LLM agents prior knowledge"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class TestInfoGainTracker:
|
| 167 |
+
|
| 168 |
+
def test_first_experiment_gives_positive_reward(self):
|
| 169 |
+
tracker = InfoGainTracker()
|
| 170 |
+
reward, is_redundant = tracker.record_and_score("A", "B", "intervention", 1.0)
|
| 171 |
+
assert reward > 0
|
| 172 |
+
assert not is_redundant
|
| 173 |
+
|
| 174 |
+
def test_repeated_experiments_become_redundant(self):
|
| 175 |
+
tracker = InfoGainTracker()
|
| 176 |
+
for _ in range(4):
|
| 177 |
+
reward, is_redundant = tracker.record_and_score("A", "B", "intervention", 1.0)
|
| 178 |
+
assert is_redundant
|
| 179 |
+
assert reward < 0
|
| 180 |
+
|
| 181 |
+
def test_different_exp_type_gives_triangulation_bonus(self):
|
| 182 |
+
tracker = InfoGainTracker()
|
| 183 |
+
tracker.record_and_score("A", "B", "intervention", 1.0)
|
| 184 |
+
reward2, _ = tracker.record_and_score("A", "B", "correlation", [1, 5, 3])
|
| 185 |
+
assert reward2 >= 0.25
|
| 186 |
+
|
| 187 |
+
def test_cumulative_gain_accumulates(self):
|
| 188 |
+
tracker = InfoGainTracker()
|
| 189 |
+
tracker.record_and_score("A", "B", "intervention", 1.0)
|
| 190 |
+
tracker.record_and_score("B", "C", "intervention", 2.0)
|
| 191 |
+
assert tracker.cumulative_gain > 0
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class TestRubric:
|
| 195 |
+
|
| 196 |
+
def _make_linear_world(self):
|
| 197 |
+
import numpy as np
|
| 198 |
+
rule = CausalRule(
|
| 199 |
+
cause="Alpha", effect="Beta",
|
| 200 |
+
rule_type="linear",
|
| 201 |
+
params={"a": 2.0, "b": 3.0},
|
| 202 |
+
description="Beta = 2.0 * Alpha + 3.0",
|
| 203 |
+
)
|
| 204 |
+
return CausalWorld(
|
| 205 |
+
domain="system_alpha",
|
| 206 |
+
variables=["Alpha", "Beta"],
|
| 207 |
+
units={"Alpha": "units", "Beta": "units"},
|
| 208 |
+
rules=[rule],
|
| 209 |
+
default_values={"Alpha": 5.0, "Beta": 13.0},
|
| 210 |
+
rng=np.random.default_rng(0),
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
def test_perfect_linear_hypothesis_scores_high(self):
|
| 214 |
+
world = self._make_linear_world()
|
| 215 |
+
result = score_hypothesis(
|
| 216 |
+
hypothesis_text="Beta = 2.0 * Alpha + 3.0. Linear relationship.",
|
| 217 |
+
hypothesis_equations=["Beta = 2.0 * Alpha + 3.0"],
|
| 218 |
+
confidence=0.9,
|
| 219 |
+
world=world,
|
| 220 |
+
budget_remaining=3,
|
| 221 |
+
budget_total=10,
|
| 222 |
+
)
|
| 223 |
+
assert result.accuracy_score >= 0.70
|
| 224 |
+
|
| 225 |
+
def test_empty_hypothesis_scores_zero(self):
|
| 226 |
+
world = self._make_linear_world()
|
| 227 |
+
result = score_hypothesis(
|
| 228 |
+
hypothesis_text="",
|
| 229 |
+
hypothesis_equations=None,
|
| 230 |
+
confidence=None,
|
| 231 |
+
world=world,
|
| 232 |
+
budget_remaining=0,
|
| 233 |
+
budget_total=10,
|
| 234 |
+
)
|
| 235 |
+
assert result.accuracy_score < 0.10
|
| 236 |
+
|
| 237 |
+
def test_efficiency_bonus_for_early_submit(self):
|
| 238 |
+
world = self._make_linear_world()
|
| 239 |
+
result = score_hypothesis(
|
| 240 |
+
hypothesis_text="Beta = 2.0 * Alpha + 3.0",
|
| 241 |
+
hypothesis_equations=["Beta = 2.0 * Alpha + 3.0"],
|
| 242 |
+
confidence=0.9,
|
| 243 |
+
world=world,
|
| 244 |
+
budget_remaining=5,
|
| 245 |
+
budget_total=10,
|
| 246 |
+
)
|
| 247 |
+
assert result.efficiency_bonus > 0.0
|
| 248 |
+
|
| 249 |
+
def test_no_efficiency_bonus_when_budget_exhausted(self):
|
| 250 |
+
world = self._make_linear_world()
|
| 251 |
+
result = score_hypothesis(
|
| 252 |
+
hypothesis_text="Beta = 2.0 * Alpha + 3.0",
|
| 253 |
+
hypothesis_equations=None,
|
| 254 |
+
confidence=0.9,
|
| 255 |
+
world=world,
|
| 256 |
+
budget_remaining=0,
|
| 257 |
+
budget_total=10,
|
| 258 |
+
)
|
| 259 |
+
assert result.efficiency_bonus == 0.0
|
| 260 |
+
|
| 261 |
+
def test_overconfident_calibration_penalised(self):
|
| 262 |
+
world = self._make_linear_world()
|
| 263 |
+
result = score_hypothesis(
|
| 264 |
+
hypothesis_text="I have no idea",
|
| 265 |
+
hypothesis_equations=None,
|
| 266 |
+
confidence=0.99,
|
| 267 |
+
world=world,
|
| 268 |
+
budget_remaining=0,
|
| 269 |
+
budget_total=10,
|
| 270 |
+
)
|
| 271 |
+
assert result.calibration_score <= 0.05
|
| 272 |
+
|
| 273 |
+
def test_feedback_text_is_not_empty(self):
|
| 274 |
+
world = self._make_linear_world()
|
| 275 |
+
result = score_hypothesis(
|
| 276 |
+
hypothesis_text="Alpha causes Beta to increase linearly",
|
| 277 |
+
hypothesis_equations=None,
|
| 278 |
+
confidence=0.7,
|
| 279 |
+
world=world,
|
| 280 |
+
budget_remaining=2,
|
| 281 |
+
budget_total=10,
|
| 282 |
+
)
|
| 283 |
+
assert len(result.feedback) > 10
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
class TestEnvironmentIntegration:
|
| 287 |
+
|
| 288 |
+
def test_full_episode_with_submit(self):
|
| 289 |
+
env = HypothesisLabEnvironment()
|
| 290 |
+
obs = env.reset(seed=42, noise_level="low", domain="physics")
|
| 291 |
+
assert obs.budget_remaining > 0
|
| 292 |
+
assert len(obs.available_variables) >= 2
|
| 293 |
+
assert not obs.done
|
| 294 |
+
|
| 295 |
+
vars_ = obs.available_variables
|
| 296 |
+
action = HypLabAction(
|
| 297 |
+
action_type=ActionType.EXPERIMENT,
|
| 298 |
+
experiment_type=ExperimentType.INTERVENTION,
|
| 299 |
+
control_variable=vars_[0],
|
| 300 |
+
target_variable=vars_[1],
|
| 301 |
+
control_value=5.0,
|
| 302 |
+
)
|
| 303 |
+
obs = env.step(action)
|
| 304 |
+
assert obs.result_value is not None
|
| 305 |
+
assert not obs.done
|
| 306 |
+
|
| 307 |
+
submit = HypLabAction(
|
| 308 |
+
action_type=ActionType.SUBMIT,
|
| 309 |
+
hypothesis_text=f"{vars_[1]} is linearly related to {vars_[0]}",
|
| 310 |
+
hypothesis_equations=[f"{vars_[1]} = a * {vars_[0]} + b"],
|
| 311 |
+
confidence=0.6,
|
| 312 |
+
)
|
| 313 |
+
obs = env.step(submit)
|
| 314 |
+
assert obs.done
|
| 315 |
+
assert obs.total_episode_reward is not None
|
| 316 |
+
assert obs.ground_truth_revealed is not None
|
| 317 |
+
|
| 318 |
+
def test_budget_exhaustion_ends_episode(self):
|
| 319 |
+
env = HypothesisLabEnvironment()
|
| 320 |
+
obs = env.reset(seed=42, noise_level="low")
|
| 321 |
+
budget = obs.budget_remaining
|
| 322 |
+
vars_ = obs.available_variables
|
| 323 |
+
|
| 324 |
+
for _ in range(budget):
|
| 325 |
+
if obs.done:
|
| 326 |
+
break
|
| 327 |
+
action = HypLabAction(
|
| 328 |
+
action_type=ActionType.EXPERIMENT,
|
| 329 |
+
experiment_type=ExperimentType.INTERVENTION,
|
| 330 |
+
control_variable=vars_[0],
|
| 331 |
+
target_variable=vars_[1],
|
| 332 |
+
control_value=5.0,
|
| 333 |
+
)
|
| 334 |
+
obs = env.step(action)
|
| 335 |
+
|
| 336 |
+
assert obs.done or obs.budget_remaining == 0
|
| 337 |
+
|
| 338 |
+
def test_redundant_experiment_gets_penalty(self):
|
| 339 |
+
env = HypothesisLabEnvironment()
|
| 340 |
+
obs = env.reset(seed=42, noise_level="low")
|
| 341 |
+
vars_ = obs.available_variables
|
| 342 |
+
|
| 343 |
+
action = HypLabAction(
|
| 344 |
+
action_type=ActionType.EXPERIMENT,
|
| 345 |
+
experiment_type=ExperimentType.INTERVENTION,
|
| 346 |
+
control_variable=vars_[0],
|
| 347 |
+
target_variable=vars_[1],
|
| 348 |
+
control_value=5.0,
|
| 349 |
+
)
|
| 350 |
+
for _ in range(4):
|
| 351 |
+
obs = env.step(action)
|
| 352 |
+
if obs.done:
|
| 353 |
+
break
|
| 354 |
+
|
| 355 |
+
assert obs.is_redundant
|
| 356 |
+
assert obs.info_gain_reward < 0
|
| 357 |
+
|
| 358 |
+
def test_invalid_variable_returns_error(self):
|
| 359 |
+
env = HypothesisLabEnvironment()
|
| 360 |
+
env.reset(seed=42, noise_level="low")
|
| 361 |
+
|
| 362 |
+
action = HypLabAction(
|
| 363 |
+
action_type=ActionType.EXPERIMENT,
|
| 364 |
+
experiment_type=ExperimentType.INTERVENTION,
|
| 365 |
+
control_variable="NONEXISTENT_VAR",
|
| 366 |
+
target_variable="ALSO_NONEXISTENT",
|
| 367 |
+
control_value=5.0,
|
| 368 |
+
)
|
| 369 |
+
obs = env.step(action)
|
| 370 |
+
assert "Error" in obs.system_message or "Unknown" in obs.system_message
|
| 371 |
+
|
| 372 |
+
def test_state_does_not_leak_hidden_world(self):
|
| 373 |
+
env = HypothesisLabEnvironment()
|
| 374 |
+
env.reset(seed=42, noise_level="low")
|
| 375 |
+
st = env.state
|
| 376 |
+
|
| 377 |
+
state_str = str(st.model_dump())
|
| 378 |
+
assert "rule_type" not in state_str
|
| 379 |
+
assert "params" not in state_str
|
| 380 |
+
|
| 381 |
+
def test_multiple_domains_all_work(self):
|
| 382 |
+
for domain in ["system_alpha", "system_beta", "system_gamma", "system_delta"]:
|
| 383 |
+
env = HypothesisLabEnvironment()
|
| 384 |
+
obs = env.reset(seed=42, domain=domain, noise_level="medium")
|
| 385 |
+
assert not obs.done
|
| 386 |
+
assert obs.budget_remaining > 0
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
class TestGraders:
|
| 390 |
+
|
| 391 |
+
def test_grader_easy_returns_valid_range(self):
|
| 392 |
+
score = grade_easy({
|
| 393 |
+
"accuracy_score": 0.8,
|
| 394 |
+
"efficiency_bonus": 0.15,
|
| 395 |
+
"calibration_score": 0.20,
|
| 396 |
+
})
|
| 397 |
+
assert 0.0 <= score <= 1.0
|
| 398 |
+
|
| 399 |
+
def test_grader_medium_returns_valid_range(self):
|
| 400 |
+
score = grade_medium({
|
| 401 |
+
"accuracy_score": 0.5,
|
| 402 |
+
"precision_bonus": 0.10,
|
| 403 |
+
"efficiency_bonus": 0.07,
|
| 404 |
+
"calibration_score": 0.10,
|
| 405 |
+
})
|
| 406 |
+
assert 0.0 <= score <= 1.0
|
| 407 |
+
|
| 408 |
+
def test_grader_hard_returns_valid_range(self):
|
| 409 |
+
score = grade_hard({
|
| 410 |
+
"accuracy_score": 0.3,
|
| 411 |
+
"precision_bonus": 0.0,
|
| 412 |
+
"efficiency_bonus": 0.0,
|
| 413 |
+
"calibration_score": 0.05,
|
| 414 |
+
"contradiction_penalty": 0.0,
|
| 415 |
+
})
|
| 416 |
+
assert 0.0 <= score <= 1.0
|
| 417 |
+
|
| 418 |
+
def test_grader_zero_input_returns_zero(self):
|
| 419 |
+
score = grade_easy({})
|
| 420 |
+
assert score == 0.0
|
| 421 |
+
|
| 422 |
+
def test_grader_perfect_input_returns_one(self):
|
| 423 |
+
score = grade_easy({
|
| 424 |
+
"accuracy_score": 1.0,
|
| 425 |
+
"efficiency_bonus": 0.15,
|
| 426 |
+
"calibration_score": 0.20,
|
| 427 |
+
})
|
| 428 |
+
assert score == pytest.approx(1.0)
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|