kgdrathan commited on
Commit
43f41de
·
verified ·
1 Parent(s): eb1ebe6

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -11,26 +11,65 @@ tags:
11
  - openenv
12
  ---
13
 
14
- # Research Interactive Explainer Environment
15
 
16
- An OpenEnv RL environment that trains small language models to create interactive educational content. Given a research topic, the agent:
17
 
18
- 1. **Explores** — searches HuggingFace Papers (ML topics) or Wikipedia (general topics) for relevant content
19
- 2. **Generates** — produces a **Marimo** reactive notebook or **Manim** math animation explaining the topic
20
 
21
- The agent learns *what* to search, *when to stop exploring*, and how to produce high-quality interactive explanations.
 
 
 
 
 
 
 
 
22
 
23
- ## Episode Flow
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  ```
26
- reset() → topic + tier assigned
27
-
28
- explore × 0..3 → search queries, accumulate research context
29
-
30
- generate × 1 → produce marimo/manim code → episode ends
 
 
 
 
31
  ```
32
 
33
- Each step returns a per-step reward. See [rewards/README.md](rewards/README.md) for the full reward breakdown.
 
 
 
 
 
 
 
 
34
 
35
  ## Quick Start
36
 
@@ -49,7 +88,12 @@ with ExplainerEnv(base_url='http://localhost:8000').sync() as sc:
49
  print(f'Topic: {result.observation.topic}, Tier: {result.observation.tier}')
50
 
51
  # Explore
52
- result = sc.step(ExplainerAction(action_type='explore', query=result.observation.topic))
 
 
 
 
 
53
  print(f'Explore reward: {result.reward:.3f}')
54
 
55
  # Generate
@@ -62,30 +106,9 @@ with ExplainerEnv(base_url='http://localhost:8000').sync() as sc:
62
  "
63
  ```
64
 
65
- ## LLM-as-Judge (Optional Eval)
66
-
67
- For final evaluation of explanation quality, an optional LLM judge scores outputs on clarity, accuracy, engagement, completeness, and appropriateness.
68
-
69
- **Not used during training** — too slow and non-deterministic for RL rewards. Training uses 12 fast heuristic reward components instead.
70
-
71
- ```bash
72
- # Configure (any OpenAI-compatible endpoint)
73
- export JUDGE_API_URL="http://localhost:11434/v1" # e.g. ollama
74
- export JUDGE_MODEL="llama3"
75
-
76
- # Usage
77
- python -c "
78
- from rewards.llm_judge import judge_explainability, is_available
79
- if is_available():
80
- score, details = judge_explainability(code='...', topic='Linear Regression', tier='beginner')
81
- print(f'Score: {score:.2f}, Rationale: {details.get(\"rationale\", \"\")}')"
82
- ```
83
-
84
- See [rewards/README.md](rewards/README.md) for full configuration details.
85
-
86
  ## Concurrent WebSocket Sessions
87
 
88
- The server supports multiple concurrent WebSocket connections for parallel training rollouts:
89
 
90
  ```python
91
  from client import ExplainerEnv
@@ -95,7 +118,11 @@ from concurrent.futures import ThreadPoolExecutor
95
  def run_episode(client_id: int):
96
  with ExplainerEnv(base_url="http://localhost:8000").sync() as sc:
97
  result = sc.reset()
98
- result = sc.step(ExplainerAction(action_type="explore", query=result.observation.topic))
 
 
 
 
99
  result = sc.step(ExplainerAction(
100
  action_type="generate", format="marimo",
101
  code="import marimo as mo\napp = mo.App()\n@app.cell\ndef _():\n return\n",
@@ -105,3 +132,39 @@ def run_episode(client_id: int):
105
  with ThreadPoolExecutor(max_workers=4) as executor:
106
  results = list(executor.map(run_episode, range(4)))
107
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  - openenv
12
  ---
13
 
14
+ # Research -> Interactive Explainer Environment
15
 
16
+ An OpenEnv RL environment that trains small language models to create interactive educational content. Given a STEM topic, the agent explores with explicit research tools, generates a **Marimo** reactive notebook or **Manim** math animation, and gets one repair attempt if lint/build validation fails.
17
 
18
+ ## Episode Flow
 
19
 
20
+ ```
21
+ reset() --> topic + tier assigned
22
+ |
23
+ explore x 0..3 --> choose research tools + queries
24
+ |
25
+ generate x 1 --> produce marimo/manim code
26
+ |
27
+ repair x 0..1 --> fix lint/build errors if needed --> episode ends
28
+ ```
29
 
30
+ ## Actions
31
 
32
+ **Explore** -- search for information relevant to the assigned topic:
33
+ ```python
34
+ ExplainerAction(
35
+ action_type="explore",
36
+ tool="search_arxiv",
37
+ query="merge sort divide and conquer visual explanation",
38
+ intent="find examples and visual intuition",
39
+ )
40
+ ```
41
+
42
+ Available tools: `search_wikipedia`, `search_hf_papers`, `search_arxiv`, `search_scholar`, `fetch_docs`, and `search_hf_hub`.
43
+
44
+ **Generate** -- produce educational code using accumulated research:
45
+ ```python
46
+ ExplainerAction(
47
+ action_type="generate",
48
+ format="marimo", # or "manim"
49
+ code="import marimo...",
50
+ narration="...", # manim only
51
+ )
52
  ```
53
+
54
+ **Repair** -- revise generated code using lint/build feedback:
55
+ ```python
56
+ ExplainerAction(
57
+ action_type="repair",
58
+ format="marimo",
59
+ code="import marimo...",
60
+ repair_notes="fixed the reported Marimo validation error",
61
+ )
62
  ```
63
 
64
+ ## Reward System
65
+
66
+ Multi-component rewards across exploration, generation, and repair. See [rewards/README.md](rewards/README.md) for the full breakdown.
67
+
68
+ **Exploration** (per-step): tool choice, query quality, source quality, coverage delta, novelty, diversity, gated by information sufficiency. Step cost of -0.05 forces the agent to justify each search.
69
+
70
+ **Generation/repair**: keyword coverage, format match, structural quality (via `marimo check` CLI or manim scene analysis), narration (manim only), context usage, and repair success.
71
+
72
+ Key design: `marimo check` CLI catches 5 breaking rules (MB001-MB005) in ~100ms. Code that doesn't parse scores 0. Code that doesn't execute gets quality * 0.4.
73
 
74
  ## Quick Start
75
 
 
88
  print(f'Topic: {result.observation.topic}, Tier: {result.observation.tier}')
89
 
90
  # Explore
91
+ result = sc.step(ExplainerAction(
92
+ action_type='explore',
93
+ tool='search_wikipedia',
94
+ query=result.observation.topic,
95
+ intent='overview',
96
+ ))
97
  print(f'Explore reward: {result.reward:.3f}')
98
 
99
  # Generate
 
106
  "
107
  ```
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  ## Concurrent WebSocket Sessions
110
 
111
+ The server supports multiple concurrent WebSocket connections for parallel GRPO training rollouts:
112
 
113
  ```python
114
  from client import ExplainerEnv
 
118
  def run_episode(client_id: int):
119
  with ExplainerEnv(base_url="http://localhost:8000").sync() as sc:
120
  result = sc.reset()
121
+ result = sc.step(ExplainerAction(
122
+ action_type="explore",
123
+ tool="search_wikipedia",
124
+ query=result.observation.topic,
125
+ ))
126
  result = sc.step(ExplainerAction(
127
  action_type="generate", format="marimo",
128
  code="import marimo as mo\napp = mo.App()\n@app.cell\ndef _():\n return\n",
 
132
  with ThreadPoolExecutor(max_workers=4) as executor:
133
  results = list(executor.map(run_episode, range(4)))
134
  ```
135
+
136
+ ## API Endpoints
137
+
138
+ | Endpoint | Method | Description |
139
+ |---|---|---|
140
+ | `/reset` | POST | Start new episode, get topic assignment |
141
+ | `/step` | POST | Submit action, get observation + reward |
142
+ | `/state` | GET | Current episode state |
143
+ | `/schema` | GET | Action/Observation JSON schemas |
144
+ | `/ws` | WebSocket | Low-latency session for training |
145
+ | `/docs` | GET | Interactive API docs |
146
+
147
+ ## Task Bank
148
+
149
+ 26 tasks across 4 categories (ML, Math, Algorithms, Statistics), 3 difficulty levels (easy, medium, hard), and 3 audience tiers (beginner, intermediate, advanced). Each task specifies keywords for reward scoring and an optional preferred output format.
150
+
151
+ ## File Structure
152
+
153
+ ```
154
+ explainer_env/
155
+ ├── server/
156
+ │ ├── explainer_env_environment.py # Environment logic (reset/step/state)
157
+ │ ├── app.py # FastAPI server (create_app)
158
+ │ └── Dockerfile # Multi-stage Docker build
159
+ ├── rewards/
160
+ │ ├── exploration.py # Explore-phase reward components
161
+ │ ├── generation.py # Generate/repair reward components
162
+ │ ├── sources.py # Compatibility wrapper for research tools
163
+ │ ├── sandbox.py # Code validation (marimo check, AST, execution)
164
+ │ └── README.md # Reward system documentation
165
+ ├── research/ # Research tools, structured results, retrieval
166
+ ├── models.py # ExplainerAction, ExplainerObservation
167
+ ├── task_bank.py # 26 curated STEM tasks
168
+ ├── client.py # ExplainerEnv WebSocket client
169
+ └── openenv.yaml # OpenEnv manifest
170
+ ```
client.py CHANGED
@@ -6,7 +6,10 @@ from openenv.core import EnvClient
6
  from openenv.core.client_types import StepResult
7
  from openenv.core.env_server.types import State
8
 
9
- from .models import ExplainerAction, ExplainerObservation
 
 
 
10
 
11
 
12
  class ExplainerEnv(
@@ -20,7 +23,10 @@ class ExplainerEnv(
20
  ... result = sc.reset()
21
  ... # Explore phase
22
  ... result = sc.step(ExplainerAction(
23
- ... action_type="explore", query="attention mechanism transformers"
 
 
 
24
  ... ))
25
  ... # Generate phase
26
  ... result = sc.step(ExplainerAction(
 
6
  from openenv.core.client_types import StepResult
7
  from openenv.core.env_server.types import State
8
 
9
+ try:
10
+ from .models import ExplainerAction, ExplainerObservation
11
+ except ImportError: # pragma: no cover - supports direct test execution
12
+ from models import ExplainerAction, ExplainerObservation
13
 
14
 
15
  class ExplainerEnv(
 
23
  ... result = sc.reset()
24
  ... # Explore phase
25
  ... result = sc.step(ExplainerAction(
26
+ ... action_type="explore",
27
+ ... tool="search_arxiv",
28
+ ... query="attention mechanism transformers",
29
+ ... intent="visual intuition and equations",
30
  ... ))
31
  ... # Generate phase
32
  ... result = sc.step(ExplainerAction(
constants.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared limits and scoring helpers for explainer episodes."""
2
+
3
+ MAX_EXPLORE_STEPS = 3
4
+ MAX_REPAIR_STEPS = 1
5
+
6
+ AVAILABLE_TOOLS = (
7
+ "search_wikipedia",
8
+ "search_hf_papers",
9
+ "search_arxiv",
10
+ "search_scholar",
11
+ "fetch_docs",
12
+ "search_hf_hub",
13
+ )
14
+
15
+ MAX_EXPLORE_REWARD = 0.8
16
+ MAX_GENERATE_REWARD = 1.0
17
+ SUCCESS_SCORE_THRESHOLD = 0.3
18
+
19
+
20
+ def normalized_episode_score(total_reward: float) -> float:
21
+ """Normalize an episode's accumulated reward to the required [0, 1] range."""
22
+ max_possible = MAX_EXPLORE_STEPS * MAX_EXPLORE_REWARD + MAX_GENERATE_REWARD
23
+ score = total_reward / max_possible if max_possible > 0 else 0.0
24
+ return min(max(score, 0.0), 1.0)
models.py CHANGED
@@ -1,10 +1,4 @@
1
- """
2
- Data models for the Research → Interactive Explainer environment.
3
-
4
- Two-phase episode:
5
- 1. Explore: agent searches for papers/resources (1-3 steps)
6
- 2. Generate: agent produces marimo/manim code (1 step, ends episode)
7
- """
8
 
9
  from typing import Literal
10
 
@@ -12,31 +6,54 @@ from openenv.core.env_server.types import Action, Observation
12
  from pydantic import Field
13
 
14
 
 
 
 
 
 
 
 
 
 
 
15
  class ExplainerAction(Action):
16
- """Action: agent either explores (searches) or generates (produces code)."""
17
 
18
- action_type: Literal["explore", "generate"] = Field(
19
- ..., description="'explore' to search for papers, 'generate' to produce code"
 
20
  )
21
 
22
  # -- explore fields --
 
 
 
 
23
  query: str = Field(
24
  default="",
25
- description="Search query for arXiv/HF papers (used when action_type='explore')",
 
 
 
 
26
  )
27
 
28
- # -- generate fields --
29
  format: Literal["marimo", "manim"] | None = Field(
30
  default=None,
31
- description="Output format (required when action_type='generate')",
32
  )
33
  code: str = Field(
34
  default="",
35
- description="Complete Python source code (required when action_type='generate')",
36
  )
37
  narration: str = Field(
38
  default="",
39
- description="Narration script (required when format='manim')",
 
 
 
 
40
  )
41
 
42
 
@@ -55,7 +72,7 @@ class ExplainerObservation(Observation):
55
  )
56
 
57
  # -- per-step feedback --
58
- phase: Literal["explore", "generate", "done"] = Field(
59
  default="explore", description="Current episode phase"
60
  )
61
  feedback: str = Field(default="", description="Feedback on the last action")
@@ -69,3 +86,12 @@ class ExplainerObservation(Observation):
69
  explore_steps_left: int = Field(
70
  default=3, description="Remaining explore steps before forced generate"
71
  )
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for the Research -> Interactive Explainer environment."""
 
 
 
 
 
 
2
 
3
  from typing import Literal
4
 
 
6
  from pydantic import Field
7
 
8
 
9
+ ResearchTool = Literal[
10
+ "search_wikipedia",
11
+ "search_hf_papers",
12
+ "search_arxiv",
13
+ "search_scholar",
14
+ "fetch_docs",
15
+ "search_hf_hub",
16
+ ]
17
+
18
+
19
  class ExplainerAction(Action):
20
+ """Action: agent explores, generates, or repairs an artifact."""
21
 
22
+ action_type: Literal["explore", "generate", "repair"] = Field(
23
+ ...,
24
+ description="'explore' to research, 'generate' to produce code, 'repair' to fix code",
25
  )
26
 
27
  # -- explore fields --
28
+ tool: ResearchTool | None = Field(
29
+ default=None,
30
+ description="Research tool to call when action_type='explore'",
31
+ )
32
  query: str = Field(
33
  default="",
34
+ description="Research query used when action_type='explore'",
35
+ )
36
+ intent: str = Field(
37
+ default="",
38
+ description="Brief goal for the research call, e.g. equations or visual intuition",
39
  )
40
 
41
+ # -- generate / repair fields --
42
  format: Literal["marimo", "manim"] | None = Field(
43
  default=None,
44
+ description="Output format (required for generate/repair)",
45
  )
46
  code: str = Field(
47
  default="",
48
+ description="Complete Python source code (required for generate/repair)",
49
  )
50
  narration: str = Field(
51
  default="",
52
+ description="Narration script (used when format='manim')",
53
+ )
54
+ repair_notes: str = Field(
55
+ default="",
56
+ description="Short explanation of what changed when action_type='repair'",
57
  )
58
 
59
 
 
72
  )
73
 
74
  # -- per-step feedback --
75
+ phase: Literal["explore", "generate", "repair", "done"] = Field(
76
  default="explore", description="Current episode phase"
77
  )
78
  feedback: str = Field(default="", description="Feedback on the last action")
 
86
  explore_steps_left: int = Field(
87
  default=3, description="Remaining explore steps before forced generate"
88
  )
89
+ repair_attempts_left: int = Field(
90
+ default=1, description="Remaining repair attempts after failed generation"
91
+ )
92
+ last_errors: str = Field(
93
+ default="", description="Latest lint/build errors available for repair"
94
+ )
95
+ available_tools: list[str] = Field(
96
+ default_factory=list, description="Research tools available during explore"
97
+ )
notes.html ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/favicon.ico" />
6
+ <!-- Preload is necessary because we show these images when we disconnect from the server,
7
+ but at that point we cannot load these images from the server -->
8
+ <link rel="preload" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/gradient-yHQUC_QB.png" as="image" />
9
+ <link rel="preload" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/noise-60BoTA8O.png" as="image" />
10
+ <!-- Preload the fonts -->
11
+ <link rel="preload" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/Lora-VariableFont_wght-CZceb_kH.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
12
+ <link rel="preload" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/PTSans-Regular-Bam3NpBI.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
13
+ <link rel="preload" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/PTSans-Bold-C_DwAp7Z.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
14
+ <link rel="preload" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/FiraMono-Regular-CEsLFVD9.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
15
+ <link rel="preload" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/FiraMono-Medium-D5MAsWEG.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
16
+ <link rel="preload" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/FiraMono-Bold-C6PDArdf.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
17
+
18
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
19
+ <meta name="theme-color" content="#000000" />
20
+ <meta name="description" content="a marimo app" />
21
+ <link rel="apple-touch-icon" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/apple-touch-icon.png" />
22
+ <link rel="manifest" crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/manifest.json" />
23
+
24
+ <script data-marimo="true">
25
+ function __resizeIframe(obj) {
26
+ const scrollbarHeight = 20; // Max between windows, mac, and linux
27
+
28
+ function setHeight() {
29
+ // Guard against race condition where iframe isn't ready
30
+ if (!obj.contentWindow?.document?.documentElement) {
31
+ return;
32
+ }
33
+ const element = obj.contentWindow.document.documentElement;
34
+ // If there is no vertical scrollbar, we don't need to resize the iframe
35
+ if (element.scrollHeight === element.clientHeight) {
36
+ return;
37
+ }
38
+
39
+ // Create a new height that includes the scrollbar height if it's visible
40
+ const hasHorizontalScrollbar = element.scrollWidth > element.clientWidth;
41
+ const newHeight = element.scrollHeight + (hasHorizontalScrollbar ? scrollbarHeight : 0);
42
+
43
+ // Only update the height if it's different from the current height
44
+ if (obj.style.height !== `${newHeight}px`) {
45
+ obj.style.height = `${newHeight}px`;
46
+ }
47
+ }
48
+
49
+ // Resize the iframe to the height of the content and bottom scrollbar height
50
+ setHeight();
51
+
52
+ // Resize the iframe when the content changes
53
+ const resizeObserver = new ResizeObserver((_entries) => {
54
+ setHeight();
55
+ });
56
+ // Only observe if iframe content is ready
57
+ if (obj.contentWindow?.document?.body) {
58
+ resizeObserver.observe(obj.contentWindow.document.body);
59
+ }
60
+ }
61
+ </script>
62
+ <marimo-filename hidden>notes.py</marimo-filename>
63
+ <!-- TODO(Trevor): Legacy, required by VS Code plugin. Remove when plugin is updated (see marimo/server/_templates/template.py) -->
64
+ <marimo-version data-version="{{ version }}" hidden></marimo-version>
65
+ <marimo-user-config data-config="{{ user_config }}" hidden></marimo-user-config>
66
+ <marimo-server-token data-token="{{ server_token }}" hidden></marimo-server-token>
67
+ <!-- /TODO -->
68
+ <title>notes</title>
69
+ <script type="module" crossorigin crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/index-BjiE1T38.js"></script>
70
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/preload-helper-D2MJg03u.js">
71
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/chunk-LvLJmgfZ.js">
72
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/react-Bj1aDYRI.js">
73
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/compiler-runtime-B3qBwwSJ.js">
74
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/jsx-runtime-BqBOg78p.js">
75
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useEventListener-BR0C1MaI.js">
76
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/defaultLocale-JieDVWC_.js">
77
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/precisionRound-CU2C3Vxx.js">
78
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/defaultLocale-BLne0bXb.js">
79
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/vega-loader.browser-DXARUlxo.js">
80
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/utils-Bq5kLQ87.js">
81
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/clsx-Dz_KRrWq.js">
82
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/cn-DCUzRj2J.js">
83
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/badge-ChrPsdTW.js">
84
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/button-D9nb17Rw.js">
85
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/react-dom-CSu739Rf.js">
86
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/fullscreen-BDxedMYP.js">
87
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/menu-items-CwUpDHG7.js">
88
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dist-BoOh2kN5.js">
89
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/createLucideIcon-D5guW7EU.js">
90
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/check-BH35Ndha.js">
91
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/x-C-6liIBr.js">
92
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/select-5i7URBEn.js">
93
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/tooltip-Gcwqb_SK.js">
94
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/use-toast-BDYuj3zG.js">
95
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useEvent-D91BmmQi.js">
96
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/invariant-BUdrueMv.js">
97
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/isObject-DvLSfCY5.js">
98
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/_baseFor-DKD1r8uL.js">
99
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/merge-_9WLTEyt.js">
100
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/zod-H_cgTO0M.js">
101
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/utils-DIGrmLDO.js">
102
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/Deferred-DxQeE5uh.js">
103
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/uuid-DXdzqzcr.js">
104
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/DeferredRequestRegistry-DjHgMr3S.js">
105
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/constants-DMpttj8Q.js">
106
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/session-nmTRerwF.js">
107
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/config-ChCHm539.js">
108
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/requests-DIwGYs0l.js">
109
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useLifecycle-DieWOfXE.js">
110
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useNonce-DfoVjkkH.js">
111
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useTheme-DEcgJENn.js">
112
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/arrays-DYDL-3-i.js">
113
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/strings-md4mFbOQ.js">
114
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/once-DRroIaBz.js">
115
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/capabilities-C_FLIcjP.js">
116
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/createReducer-1ePoj7v6.js">
117
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/paths-CXng2XQv.js">
118
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dist-BrR4M-k3.js">
119
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dist-HlsDto3K.js">
120
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dist-CHcznzB-.js">
121
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dist-DGxytjtv.js">
122
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dist-DZ2qbK2N.js">
123
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dist-CEysMnMQ.js">
124
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dist-Ds4ite_2.js">
125
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dist-CRPTyqAT.js">
126
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dist-C9ZB41s2.js">
127
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/stex-jWatZkll.js">
128
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/toDate-B5A0DFEz.js">
129
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/purify.es-DC4RGS9t.js">
130
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/cjs-BRGiG41H.js">
131
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/isSymbol-BT6B8Bon.js">
132
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/now-CsYTPAhW.js">
133
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/debounce-DhnxH9Rh.js">
134
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/database-zap-BUVn5HoR.js">
135
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/main-B0OX4z33.js">
136
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/cells-CJlo_hG2.js">
137
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/ErrorBoundary-DzYV_VeY.js">
138
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/kbd-CGShmG7L.js">
139
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useInstallPackage-afgh7PwB.js">
140
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/alert-dialog-BGBdrcqJ.js">
141
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dialog-EekxpBBM.js">
142
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useDebounce-BDIglWmG.js">
143
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/numbers-OHL_xBiC.js">
144
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/SSRProvider-D3zWcDme.js">
145
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/context-DGqo1TbK.js">
146
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useNumberFormatter-wQU1z0W_.js">
147
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/usePress-CtfZXGno.js">
148
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/input-DyPS_GiK.js">
149
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/ImperativeModal-BBqcKmmk.js">
150
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/cell-link-BA7Demf0.js">
151
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/multi-map-rafH3cg3.js">
152
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/alert-cDholnQq.js">
153
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/chevron-right-CG5QYXYk.js">
154
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dropdown-menu-D1A3cFC8.js">
155
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/links-C-rLiK3d.js">
156
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useRunCells-B531RIUE.js">
157
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/copy-B5qyZn5s.js">
158
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/copy-Dk_3y0H-.js">
159
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/copy-icon-C67c9EwB.js">
160
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/RenderHTML-BT9obKLc.js">
161
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/datasource-BKDU-4D5.js">
162
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/state-C5AUgyZT.js">
163
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/sparkles-CZ5WmLPA.js">
164
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/MarimoErrorOutput-BBD405a6.js">
165
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/spinner-Bhir8k53.js">
166
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/html-to-image-Cz8lDF-Y.js">
167
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/focus-BX3gXJxx.js">
168
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useAsyncData-dS4Ne6pU.js">
169
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/LazyAnyLanguageCodeMirror-0gZO1asr.js">
170
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/micromark-factory-space-WzovnJik.js">
171
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/chunk-5FQGJX7Z-B4RvNAGm.js">
172
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/markdown-renderer-CeHY2KoQ.js">
173
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/command-DbT_zkRP.js">
174
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/popover-UExmgBsf.js">
175
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/errors-CZb6hI2x.js">
176
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/download-mzsKQgiy.js">
177
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/table-23NNA1s9.js">
178
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useIframeCapabilities-CryCjoyY.js">
179
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/error-banner-CTBwdcdk.js">
180
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/formats-BvyJSIqk.js">
181
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/en-US-BqRooSzc.js">
182
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/isValid-bmSEa3HX.js">
183
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/dates-DhByPWH5.js">
184
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/maps-C48Oksn0.js">
185
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/extends-Co37_JfG.js">
186
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/emotion-is-prop-valid.esm-DYxi7n2b.js">
187
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useDateFormatter-BEz9SEXo.js">
188
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/react-icons.esm-BUYTQ32a.js">
189
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/table-6NxtGaCm.js">
190
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/ellipsis-pk06Lq82.js">
191
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/message-circle-CWm2KnSx.js">
192
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/react-resizable-panels.browser.esm-Bcm5njwd.js">
193
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/JsonOutput-DXnOS_Hk.js">
194
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/chart-no-axes-column-nqk474t8.js">
195
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/square-function-D1dlJvD8.js">
196
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/spec-Cq8FVoTf.js">
197
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/ellipsis-vertical-gxgNyR5G.js">
198
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/refresh-cw-DHwG4Mac.js">
199
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/tree-actions-oMCx6WNc.js">
200
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/components-BiBOcq1x.js">
201
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/column-preview-DzN2QumC.js">
202
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/icons-BRopQwI3.js">
203
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/floating-outline-BMB4_phA.js">
204
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useAddCell-CqBbGhrY.js">
205
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/objectWithoutPropertiesLoose-smPWkHxB.js">
206
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/esm-G1JMtvxe.js">
207
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/eye-off-BT-KOYV5.js">
208
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/plus-BgB18UzY.js">
209
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/readonly-python-code-Ccd8HM-7.js">
210
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/file-headphone-B3fuktN0.js">
211
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/file-HTLbeC2b.js">
212
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/image-DQHXdEQn.js">
213
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/file-icons-BNrh8MRG.js">
214
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/switch-DlgpDZMk.js">
215
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/events-Qeh-bHlj.js">
216
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/globals-xVhZei_S.js">
217
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/share-BXN2Sd1t.js">
218
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/blob-CTort_or.js">
219
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/memoize-Tp7rARFe.js">
220
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/get-C-qh_et5.js">
221
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/_baseSet-CxV9N1bc.js">
222
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/state-DHlRrwyY.js">
223
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/label-DTR8T0AE.js">
224
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/textarea-BXPC1-kb.js">
225
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/radio-group-BtBoRbGH.js">
226
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/refresh-ccw-C-n2VFP5.js">
227
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/trash-2-D280Xiwg.js">
228
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/form-CPDlIjdV.js">
229
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/renderShortcut-CkNNAheg.js">
230
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/field-B8kJgr2A.js">
231
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/RSPContexts-BeHIgT4C.js">
232
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useBoolean-CLFdHewv.js">
233
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/useDeepCompareMemoize-D0WTlCXt.js">
234
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/types-W8WWuumF.js">
235
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/fileToBase64-Bzn96tYq.js">
236
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/pathUtils-CSrNy17a.js">
237
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/prop-types-BFRDxSKF.js">
238
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/es-YQvzKo5h.js">
239
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/hasIn-DiS_ryrS.js">
240
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/flatten-Dl4V1ub3.js">
241
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/pick-DqWUNly1.js">
242
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/code-xml-CZN6vNxu.js">
243
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/square-Dj2Cf4ne.js">
244
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/triangle-alert-DBoAWWKA.js">
245
+ <link rel="modulepreload" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/bundle.esm-DU-4isVC.js">
246
+ <link rel="stylesheet" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/cells-jmgGt1lS.css">
247
+ <link rel="stylesheet" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/markdown-renderer-DdDKmWlR.css">
248
+ <link rel="stylesheet" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/JsonOutput-B7vuddcd.css">
249
+ <link rel="stylesheet" crossorigin crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/@marimo-team/frontend@0.23.3/dist/assets/index-BYLYJcAY.css">
250
+
251
+ <script data-marimo="true">
252
+ Object.defineProperty(window, "__MARIMO_STATIC__", {
253
+ value: Object.freeze({
254
+ files: Object.freeze({}),
255
+ modelNotifications: Object.freeze([]),
256
+ }),
257
+ writable: false,
258
+ configurable: false,
259
+ });
260
+ </script>
261
+
262
+ <script data-marimo="true">
263
+ Object.defineProperty(window, "__MARIMO_EXPORT_CONTEXT__", {
264
+ value: Object.freeze({
265
+ trusted: true,
266
+ notebookCode: "import marimo\n\n__generated_with = \"0.23.3\"\napp = marimo.App()\n\n\n@app.cell\ndef _():\n import marimo as mo\n import numpy as np\n\n # Remove and warn if matplotlib is missing.\n try:\n import matplotlib.pyplot as plt\n except ModuleNotFoundError:\n plt = None\n mo.notification(\"Matplotlib is not installed. Plots will be skipped or limited.\", kind=\"warning\")\n\n from sklearn.datasets import make_regression\n from sklearn.linear_model import LinearRegression\n\n return LinearRegression, make_regression, mo, np, plt\n\n\n@app.cell\ndef _(mo):\n mo.md(\"# Linear Regression: A Beginner's Guide\")\n mo.md(\n \"Linear regression is a fundamental technique in machine learning used to model the relationship between a dependent variable and one or more independent variables.\"\n )\n mo.md(\n \"In this notebook, we'll explore what linear regression is, how it works mathematically, how to fit a model using scikit-learn, and how to implement it from scratch using gradient descent.\"\n )\n return\n\n\n@app.cell\ndef _(LinearRegression, make_regression, mo, np, plt):\n mo.md(\"## What is Linear Regression?\")\n mo.md(\n \"Linear regression aims to find the 'best fit' line through data points. This line is defined by weights (coefficients) and a bias term.\"\n )\n\n # Create sample data\n X, y = make_regression(n_samples=100, n_features=1, noise=10, random_state=42)\n\n # Fit sklearn linear regression\n lin_reg = LinearRegression()\n lin_reg.fit(X, y)\n\n # Calculate predictions\n y_pred = lin_reg.predict(X)\n\n if plt is not None:\n # Sort the data for a prettier line plot\n sorted_idx = np.argsort(X[:, 0])\n X_sorted = X[sorted_idx]\n y_pred_sorted = y_pred[sorted_idx]\n y_sorted = y[sorted_idx]\n\n # Plot\n fig, ax = plt.subplots(figsize=(10, 6))\n ax.scatter(X, y, alpha=0.6, label=\"Data points\")\n ax.plot(X_sorted, y_pred_sorted, color=\"red\", linewidth=2, label=\"Fitted line\")\n ax.set_xlabel(\"X\")\n ax.set_ylabel(\"y\")\n ax.legend()\n ax.grid(True, alpha=0.3)\n ax.set_title(\"Linear Regression Example\")\n\n mo.display(fig)\n else:\n mo.notification(\"Skipping plot: matplotlib is not available.\", kind=\"error\")\n return X, lin_reg, y, y_pred\n\n\n@app.cell\ndef _(lin_reg, mo):\n mo.md(\"## Mathematical Foundation\")\n mo.md(\"The linear regression model can be expressed as:\")\n mo.md(\"$$y = Xw + b$$\")\n mo.md(\"Where:\")\n mo.md(\"- $y$ is the target variable\")\n mo.md(\"- $X$ is the matrix of input features\")\n mo.md(\"- $w$ is the vector of weights\")\n mo.md(\"- $b$ is the bias term\")\n\n mo.md(\"In our example:\")\n mo.md(f\"- Weights (w): {lin_reg.coef_[0]:.2f}\")\n mo.md(f\"- Bias (b): {lin_reg.intercept_:.2f}\")\n return\n\n\n@app.cell\ndef _(mo, np, y, y_pred):\n mo.md(\"## Loss Function: Mean Squared Error (MSE)\")\n mo.md(\n \"The goal of linear regression is to minimize the error between predicted and actual values. The most common loss function is Mean Squared Error:\"\n )\n mo.md(\"$$\\\\text{MSE} = \\\\frac{1}{n} \\\\sum_{i=1}^{n} (y_i - \\\\hat{y}_i)^2$$\")\n mo.md(\"Where:\")\n mo.md(\"- $y_i$ is the actual value\")\n mo.md(\"- $\\\\hat{y}_i$ is the predicted value\")\n mo.md(\"- $n$ is the number of samples\")\n\n # Calculate MSE\n mse = np.mean((y - y_pred) ** 2)\n mo.md(f\"In our example, the MSE is approximately: {mse:.2f}\")\n return\n\n\n@app.cell\ndef _(X, mo, np, y):\n mo.md(\"## How Does It Work? Gradient Descent\")\n mo.md(\n \"To minimize the MSE, we can use an optimization algorithm called gradient descent. This algorithm iteratively adjusts the weights and bias to reduce the loss function.\"\n )\n\n # Simple implementation of gradient descent\n def gradient_descent(X, y, learning_rate=0.01, iterations=1000):\n m = len(X)\n w = np.random.randn(1)\n b = 0.0\n\n # Reshape X\n x_vec = X.ravel()\n for i in range(iterations):\n y_pred = x_vec * w + b\n\n # Compute gradients\n dw = (-2 / m) * np.sum(x_vec * (y - y_pred))\n db = (-2 / m) * np.sum(y - y_pred)\n\n w -= learning_rate * dw\n b -= learning_rate * db\n\n return w, b\n\n w_gd, b_gd = gradient_descent(X, y)\n y_pred_gd = X.ravel() * w_gd + b_gd\n\n mse_gd = np.mean((y - y_pred_gd) ** 2)\n\n mo.md(\"Using gradient descent (learning rate=0.01, 1000 iterations):\")\n mo.md(f\"- Weights (w): {w_gd[0]:.2f}\")\n mo.md(f\"- Bias (b): {b_gd:.2f}\")\n mo.md(f\"- MSE: {mse_gd:.2f}\")\n return (y_pred_gd,)\n\n\n@app.cell\ndef _(X, mo, np, plt, y, y_pred, y_pred_gd):\n mo.md(\"## Visualizing the Process\")\n if plt is not None:\n fig, ax = plt.subplots(figsize=(10, 6))\n ax.scatter(X, y, alpha=0.6, label=\"Data points\")\n\n # For clean lines, sort the X for both predictions\n sorted_idx = np.argsort(X[:, 0])\n X_sorted = X[sorted_idx]\n y_pred_sorted = y_pred[sorted_idx]\n y_pred_gd_sorted = y_pred_gd[sorted_idx]\n\n ax.plot(X_sorted, y_pred_sorted, color=\"red\", linewidth=2, label=\"Sklearn fit\")\n ax.plot(X_sorted, y_pred_gd_sorted, color=\"green\", linewidth=2, linestyle=\"--\", label=\"Gradient descent\")\n ax.set_xlabel(\"X\")\n ax.set_ylabel(\"y\")\n ax.legend()\n ax.grid(True, alpha=0.3)\n ax.set_title(\"Comparison: Sklearn vs Gradient Descent\")\n mo.display(fig)\n else:\n mo.notification(\"Skipping plot: matplotlib is not available.\", kind=\"error\")\n return\n\n\n@app.cell\ndef _(mo):\n mo.md(\"## Key Takeaways\")\n mo.md(\"1. Linear regression models the relationship between variables using a linear equation.\")\n mo.md(\"2. The parameters (weights and bias) are learned by minimizing the loss function.\")\n mo.md(\"3. Mean Squared Error (MSE) is a common loss function.\")\n mo.md(\"4. Gradient descent is commonly used to optimize parameters.\")\n mo.md(\"5. Even simple models like linear regression can be very effective!\")\n\n mo.md(\"---\")\n mo.md(\"**Try modifying the data, learning rate, or number of iterations above to see how the model changes!**\")\n return\n\n\nif __name__ == \"__main__\":\n app.run()\n",
267
+ }),
268
+ writable: false,
269
+ configurable: false,
270
+ });
271
+ </script>
272
+ </head>
273
+ <body>
274
+ <div id="root"></div>
275
+ <!-- This is a portal for the data editor to render in -->
276
+ <div id="portal" data-testid="glide-portal" style="position: fixed; left: 0; top: 0; z-index: 9999"></div>
277
+ <script data-marimo="true">
278
+ Object.defineProperty(window, "__MARIMO_MOUNT_CONFIG__", {
279
+ value: Object.freeze({
280
+ "filename": "notes.py",
281
+ "cwd": "",
282
+ "lspWorkspace": null,
283
+ "mode": "read",
284
+ "version": "0.23.3",
285
+ "serverToken": "static",
286
+ "config": {"ai": {"custom_providers": {}, "models": {"custom_models": [], "displayed_models": []}}, "completion": {"activate_on_typing": true, "copilot": false, "signature_hint_on_typing": false}, "diagnostics": {"sql_linter": true}, "display": {"cell_output": "below", "code_editor_font_size": 14, "dataframes": "rich", "default_table_max_columns": 50, "default_table_page_size": 10, "default_width": "medium", "reference_highlighting": true, "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "language_servers": {"pylsp": {"enable_flake8": false, "enable_mypy": true, "enable_pydocstyle": false, "enable_pyflakes": false, "enable_pylint": false, "enable_ruff": true, "enabled": false}}, "mcp": {"mcpServers": {}, "presets": []}, "package_management": {"manager": "uv"}, "runtime": {"auto_instantiate": false, "auto_reload": "off", "default_csv_encoding": "utf-8", "default_sql_output": "auto", "on_cell_change": "autorun", "output_max_bytes": 8000000, "reactive_tests": true, "show_tracebacks": false, "std_stream_max_bytes": 1000000, "watcher_on_save": "lazy"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}, "snippets": {"custom_paths": [], "include_default_snippets": true}},
287
+ "configOverrides": {},
288
+ "appConfig": {"sql_output": "auto", "width": "compact"},
289
+ "view": {"showAppCode": true},
290
+ "notebook": {"cells": [{"code": "import marimo as mo\nimport numpy as np\n\n# Remove and warn if matplotlib is missing.\ntry:\n import matplotlib.pyplot as plt\nexcept ModuleNotFoundError:\n plt = None\n mo.notification(\"Matplotlib is not installed. Plots will be skipped or limited.\", kind=\"warning\")\n\nfrom sklearn.datasets import make_regression\nfrom sklearn.linear_model import LinearRegression", "code_hash": "6311d2d636bfb7b8a25160e81c78d21d", "config": {"column": null, "disabled": false, "hide_code": false}, "id": "Hbol", "name": "_"}, {"code": "mo.md(\"# Linear Regression: A Beginner's Guide\")\nmo.md(\n \"Linear regression is a fundamental technique in machine learning used to model the relationship between a dependent variable and one or more independent variables.\"\n)\nmo.md(\n \"In this notebook, we'll explore what linear regression is, how it works mathematically, how to fit a model using scikit-learn, and how to implement it from scratch using gradient descent.\"\n)", "code_hash": "ae6176c366881e7b334d536a6a4860a5", "config": {"column": null, "disabled": false, "hide_code": false}, "id": "MJUe", "name": "_"}, {"code": "mo.md(\"## What is Linear Regression?\")\nmo.md(\n \"Linear regression aims to find the 'best fit' line through data points. This line is defined by weights (coefficients) and a bias term.\"\n)\n\n# Create sample data\nX, y = make_regression(n_samples=100, n_features=1, noise=10, random_state=42)\n\n# Fit sklearn linear regression\nlin_reg = LinearRegression()\nlin_reg.fit(X, y)\n\n# Calculate predictions\ny_pred = lin_reg.predict(X)\n\nif plt is not None:\n # Sort the data for a prettier line plot\n sorted_idx = np.argsort(X[:, 0])\n X_sorted = X[sorted_idx]\n y_pred_sorted = y_pred[sorted_idx]\n y_sorted = y[sorted_idx]\n\n # Plot\n fig, ax = plt.subplots(figsize=(10, 6))\n ax.scatter(X, y, alpha=0.6, label=\"Data points\")\n ax.plot(X_sorted, y_pred_sorted, color=\"red\", linewidth=2, label=\"Fitted line\")\n ax.set_xlabel(\"X\")\n ax.set_ylabel(\"y\")\n ax.legend()\n ax.grid(True, alpha=0.3)\n ax.set_title(\"Linear Regression Example\")\n\n mo.display(fig)\nelse:\n mo.notification(\"Skipping plot: matplotlib is not available.\", kind=\"error\")", "code_hash": "170ff6c75442add92b65232c51558154", "config": {"column": null, "disabled": false, "hide_code": false}, "id": "vblA", "name": "_"}, {"code": "mo.md(\"## Mathematical Foundation\")\nmo.md(\"The linear regression model can be expressed as:\")\nmo.md(\"$$y = Xw + b$$\")\nmo.md(\"Where:\")\nmo.md(\"- $y$ is the target variable\")\nmo.md(\"- $X$ is the matrix of input features\")\nmo.md(\"- $w$ is the vector of weights\")\nmo.md(\"- $b$ is the bias term\")\n\nmo.md(\"In our example:\")\nmo.md(f\"- Weights (w): {lin_reg.coef_[0]:.2f}\")\nmo.md(f\"- Bias (b): {lin_reg.intercept_:.2f}\")", "code_hash": "59a3865c53fa126bd4cf2f338bb8a1d3", "config": {"column": null, "disabled": false, "hide_code": false}, "id": "bkHC", "name": "_"}, {"code": "mo.md(\"## Loss Function: Mean Squared Error (MSE)\")\nmo.md(\n \"The goal of linear regression is to minimize the error between predicted and actual values. The most common loss function is Mean Squared Error:\"\n)\nmo.md(\"$$\\\\text{MSE} = \\\\frac{1}{n} \\\\sum_{i=1}^{n} (y_i - \\\\hat{y}_i)^2$$\")\nmo.md(\"Where:\")\nmo.md(\"- $y_i$ is the actual value\")\nmo.md(\"- $\\\\hat{y}_i$ is the predicted value\")\nmo.md(\"- $n$ is the number of samples\")\n\n# Calculate MSE\nmse = np.mean((y - y_pred) ** 2)\nmo.md(f\"In our example, the MSE is approximately: {mse:.2f}\")", "code_hash": "de9208afe44eff71d520ff697cad4770", "config": {"column": null, "disabled": false, "hide_code": false}, "id": "lEQa", "name": "_"}, {"code": "mo.md(\"## How Does It Work? Gradient Descent\")\nmo.md(\n \"To minimize the MSE, we can use an optimization algorithm called gradient descent. This algorithm iteratively adjusts the weights and bias to reduce the loss function.\"\n)\n\n# Simple implementation of gradient descent\ndef gradient_descent(X, y, learning_rate=0.01, iterations=1000):\n m = len(X)\n w = np.random.randn(1)\n b = 0.0\n\n # Reshape X\n x_vec = X.ravel()\n for i in range(iterations):\n y_pred = x_vec * w + b\n\n # Compute gradients\n dw = (-2 / m) * np.sum(x_vec * (y - y_pred))\n db = (-2 / m) * np.sum(y - y_pred)\n\n w -= learning_rate * dw\n b -= learning_rate * db\n\n return w, b\n\nw_gd, b_gd = gradient_descent(X, y)\ny_pred_gd = X.ravel() * w_gd + b_gd\n\nmse_gd = np.mean((y - y_pred_gd) ** 2)\n\nmo.md(\"Using gradient descent (learning rate=0.01, 1000 iterations):\")\nmo.md(f\"- Weights (w): {w_gd[0]:.2f}\")\nmo.md(f\"- Bias (b): {b_gd:.2f}\")\nmo.md(f\"- MSE: {mse_gd:.2f}\")", "code_hash": "df993a47fd4bb8dc97b0a17617769ede", "config": {"column": null, "disabled": false, "hide_code": false}, "id": "PKri", "name": "_"}, {"code": "mo.md(\"## Visualizing the Process\")\nif plt is not None:\n fig, ax = plt.subplots(figsize=(10, 6))\n ax.scatter(X, y, alpha=0.6, label=\"Data points\")\n\n # For clean lines, sort the X for both predictions\n sorted_idx = np.argsort(X[:, 0])\n X_sorted = X[sorted_idx]\n y_pred_sorted = y_pred[sorted_idx]\n y_pred_gd_sorted = y_pred_gd[sorted_idx]\n\n ax.plot(X_sorted, y_pred_sorted, color=\"red\", linewidth=2, label=\"Sklearn fit\")\n ax.plot(X_sorted, y_pred_gd_sorted, color=\"green\", linewidth=2, linestyle=\"--\", label=\"Gradient descent\")\n ax.set_xlabel(\"X\")\n ax.set_ylabel(\"y\")\n ax.legend()\n ax.grid(True, alpha=0.3)\n ax.set_title(\"Comparison: Sklearn vs Gradient Descent\")\n mo.display(fig)\nelse:\n mo.notification(\"Skipping plot: matplotlib is not available.\", kind=\"error\")", "code_hash": "306bc2687366587cb56a645ff49a1445", "config": {"column": null, "disabled": false, "hide_code": false}, "id": "Xref", "name": "_"}, {"code": "mo.md(\"## Key Takeaways\")\nmo.md(\"1. Linear regression models the relationship between variables using a linear equation.\")\nmo.md(\"2. The parameters (weights and bias) are learned by minimizing the loss function.\")\nmo.md(\"3. Mean Squared Error (MSE) is a common loss function.\")\nmo.md(\"4. Gradient descent is commonly used to optimize parameters.\")\nmo.md(\"5. Even simple models like linear regression can be very effective!\")\n\nmo.md(\"---\")\nmo.md(\"**Try modifying the data, learning rate, or number of iterations above to see how the model changes!**\")", "code_hash": "87605417fa79512c613421afe7a9f671", "config": {"column": null, "disabled": false, "hide_code": false}, "id": "SFPL", "name": "_"}], "metadata": {"marimo_version": "0.23.3"}, "version": "1"},
291
+ "session": {"cells": [{"code_hash": "6311d2d636bfb7b8a25160e81c78d21d", "console": [{"mimetype": "application/vnd.marimo+traceback", "name": "stderr", "text": "\u003Cspan class=\"codehilite\"\u003E\u003Cdiv class=\"highlight\"\u003E\u003Cpre\u003E\u003Cspan\u003E\u003C/span\u003E\u003Cspan class=\"gt\"\u003ETraceback (most recent call last):\u003C/span\u003E\n File \u003Cspan class=\"nb\"\u003E\u0026quot;/var/folders/44/xrn2r2n539lcfpfq4mb99p48hlq2mb/T/marimo_89694/__marimo__cell_Hbol_.py\u0026quot;\u003C/span\u003E, line \u003Cspan class=\"m\"\u003E6\u003C/span\u003E, in \u003Cspan class=\"n\"\u003E\u0026lt;module\u0026gt;\u003C/span\u003E\n\u003Cspan class=\"w\"\u003E \u003C/span\u003E\u003Cspan class=\"kn\"\u003Eimport\u003C/span\u003E\u003Cspan class=\"w\"\u003E \u003C/span\u003E\u003Cspan class=\"nn\"\u003Ematplotlib.pyplot\u003C/span\u003E\u003Cspan class=\"w\"\u003E \u003C/span\u003E\u003Cspan class=\"k\"\u003Eas\u003C/span\u003E\u003Cspan class=\"w\"\u003E \u003C/span\u003E\u003Cspan class=\"nn\"\u003Eplt\u003C/span\u003E\n\u003Cspan class=\"gr\"\u003EModuleNotFoundError\u003C/span\u003E: \u003Cspan class=\"n\"\u003ENo module named \u0026#39;matplotlib\u0026#39;\u003C/span\u003E\n\n\u003Cspan class=\"gt\"\u003EDuring handling of the above exception, another exception occurred:\u003C/span\u003E\n\n\u003Cspan class=\"gt\"\u003ETraceback (most recent call last):\u003C/span\u003E\n File \u003Cspan class=\"nb\"\u003E\u0026quot;/Users/mmt10913/Personal/hackathons/openenv-hackathon/explainer_env/.venv/lib/python3.12/site-packages/marimo/_runtime/executor.py\u0026quot;\u003C/span\u003E, line \u003Cspan class=\"m\"\u003E138\u003C/span\u003E, in \u003Cspan class=\"n\"\u003Eexecute_cell\u003C/span\u003E\n\u003Cspan class=\"w\"\u003E \u003C/span\u003E\u003Cspan class=\"n\"\u003Eexec\u003C/span\u003E\u003Cspan class=\"p\"\u003E(\u003C/span\u003E\u003Cspan class=\"n\"\u003Ecell\u003C/span\u003E\u003Cspan class=\"o\"\u003E.\u003C/span\u003E\u003Cspan class=\"n\"\u003Ebody\u003C/span\u003E\u003Cspan class=\"p\"\u003E,\u003C/span\u003E \u003Cspan class=\"n\"\u003Eglbls\u003C/span\u003E\u003Cspan class=\"p\"\u003E)\u003C/span\u003E\n File \u003Cspan class=\"nb\"\u003E\u0026quot;/var/folders/44/xrn2r2n539lcfpfq4mb99p48hlq2mb/T/marimo_89694/__marimo__cell_Hbol_.py\u0026quot;\u003C/span\u003E, line \u003Cspan class=\"m\"\u003E9\u003C/span\u003E, in \u003Cspan class=\"n\"\u003E\u0026lt;module\u0026gt;\u003C/span\u003E\n\u003Cspan class=\"w\"\u003E \u003C/span\u003E\u003Cspan class=\"n\"\u003Emo\u003C/span\u003E\u003Cspan class=\"o\"\u003E.\u003C/span\u003E\u003Cspan class=\"n\"\u003Enotification\u003C/span\u003E\u003Cspan class=\"p\"\u003E(\u003C/span\u003E\u003Cspan class=\"s2\"\u003E\u0026quot;Matplotlib is not installed. Plots will be skipped or limited.\u0026quot;\u003C/span\u003E\u003Cspan class=\"p\"\u003E,\u003C/span\u003E \u003Cspan class=\"n\"\u003Ekind\u003C/span\u003E\u003Cspan class=\"o\"\u003E=\u003C/span\u003E\u003Cspan class=\"s2\"\u003E\u0026quot;warning\u0026quot;\u003C/span\u003E\u003Cspan class=\"p\"\u003E)\u003C/span\u003E\n\u003Cspan class=\"w\"\u003E \u003C/span\u003E\u003Cspan class=\"pm\"\u003E^^^^^^^^^^^^^^^\u003C/span\u003E\n\u003Cspan class=\"gr\"\u003EAttributeError\u003C/span\u003E: \u003Cspan class=\"n\"\u003Emodule \u0026#39;marimo\u0026#39; has no attribute \u0026#39;notification\u0026#39;\u003C/span\u003E\n\u003C/pre\u003E\u003C/div\u003E\n\u003C/span\u003E", "type": "stream"}], "id": "Hbol", "outputs": [{"ename": "exception", "evalue": "module 'marimo' has no attribute 'notification'", "traceback": null, "type": "error"}]}, {"code_hash": "ae6176c366881e7b334d536a6a4860a5", "console": [], "id": "MJUe", "outputs": [{"ename": "exception", "evalue": "An ancestor raised an exception (AttributeError): ", "traceback": null, "type": "error"}]}, {"code_hash": "170ff6c75442add92b65232c51558154", "console": [], "id": "vblA", "outputs": [{"ename": "multiple-defs", "evalue": "The variable 'X_sorted' was defined by another cell", "traceback": [], "type": "error"}, {"ename": "multiple-defs", "evalue": "The variable 'ax' was defined by another cell", "traceback": [], "type": "error"}, {"ename": "multiple-defs", "evalue": "The variable 'fig' was defined by another cell", "traceback": [], "type": "error"}, {"ename": "multiple-defs", "evalue": "The variable 'sorted_idx' was defined by another cell", "traceback": [], "type": "error"}, {"ename": "multiple-defs", "evalue": "The variable 'y_pred_sorted' was defined by another cell", "traceback": [], "type": "error"}]}, {"code_hash": "59a3865c53fa126bd4cf2f338bb8a1d3", "console": [], "id": "bkHC", "outputs": [{"ename": "exception", "evalue": "An ancestor raised an exception (AttributeError): ", "traceback": null, "type": "error"}]}, {"code_hash": "de9208afe44eff71d520ff697cad4770", "console": [], "id": "lEQa", "outputs": [{"ename": "exception", "evalue": "An ancestor raised an exception (AttributeError): ", "traceback": null, "type": "error"}]}, {"code_hash": "df993a47fd4bb8dc97b0a17617769ede", "console": [], "id": "PKri", "outputs": [{"ename": "exception", "evalue": "An ancestor raised an exception (AttributeError): ", "traceback": null, "type": "error"}]}, {"code_hash": "306bc2687366587cb56a645ff49a1445", "console": [], "id": "Xref", "outputs": [{"ename": "multiple-defs", "evalue": "The variable 'X_sorted' was defined by another cell", "traceback": [], "type": "error"}, {"ename": "multiple-defs", "evalue": "The variable 'ax' was defined by another cell", "traceback": [], "type": "error"}, {"ename": "multiple-defs", "evalue": "The variable 'fig' was defined by another cell", "traceback": [], "type": "error"}, {"ename": "multiple-defs", "evalue": "The variable 'sorted_idx' was defined by another cell", "traceback": [], "type": "error"}, {"ename": "multiple-defs", "evalue": "The variable 'y_pred_sorted' was defined by another cell", "traceback": [], "type": "error"}]}, {"code_hash": "87605417fa79512c613421afe7a9f671", "console": [], "id": "SFPL", "outputs": [{"ename": "exception", "evalue": "An ancestor raised an exception (AttributeError): ", "traceback": null, "type": "error"}]}], "metadata": {"marimo_version": "0.23.3", "script_metadata_hash": null}, "version": "1"},
292
+ "runtimeConfig": null,
293
+ }),
294
+ writable: false,
295
+ configurable: false,
296
+ });
297
+ </script>
298
+
299
+ <marimo-code hidden="">
300
+ import%20marimo%0A%0A__generated_with%20%3D%20%220.23.3%22%0Aapp%20%3D%20marimo.App()%0A%0A%0A%40app.cell%0Adef%20_()%3A%0A%20%20%20%20import%20marimo%20as%20mo%0A%20%20%20%20import%20numpy%20as%20np%0A%0A%20%20%20%20%23%20Remove%20and%20warn%20if%20matplotlib%20is%20missing.%0A%20%20%20%20try%3A%0A%20%20%20%20%20%20%20%20import%20matplotlib.pyplot%20as%20plt%0A%20%20%20%20except%20ModuleNotFoundError%3A%0A%20%20%20%20%20%20%20%20plt%20%3D%20None%0A%20%20%20%20%20%20%20%20mo.notification(%22Matplotlib%20is%20not%20installed.%20Plots%20will%20be%20skipped%20or%20limited.%22%2C%20kind%3D%22warning%22)%0A%0A%20%20%20%20from%20sklearn.datasets%20import%20make_regression%0A%20%20%20%20from%20sklearn.linear_model%20import%20LinearRegression%0A%0A%20%20%20%20return%20LinearRegression%2C%20make_regression%2C%20mo%2C%20np%2C%20plt%0A%0A%0A%40app.cell%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(%22%23%20Linear%20Regression%3A%20A%20Beginner's%20Guide%22)%0A%20%20%20%20mo.md(%0A%20%20%20%20%20%20%20%20%22Linear%20regression%20is%20a%20fundamental%20technique%20in%20machine%20learning%20used%20to%20model%20the%20relationship%20between%20a%20dependent%20variable%20and%20one%20or%20more%20independent%20variables.%22%0A%20%20%20%20)%0A%20%20%20%20mo.md(%0A%20%20%20%20%20%20%20%20%22In%20this%20notebook%2C%20we'll%20explore%20what%20linear%20regression%20is%2C%20how%20it%20works%20mathematically%2C%20how%20to%20fit%20a%20model%20using%20scikit-learn%2C%20and%20how%20to%20implement%20it%20from%20scratch%20using%20gradient%20descent.%22%0A%20%20%20%20)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(LinearRegression%2C%20make_regression%2C%20mo%2C%20np%2C%20plt)%3A%0A%20%20%20%20mo.md(%22%23%23%20What%20is%20Linear%20Regression%3F%22)%0A%20%20%20%20mo.md(%0A%20%20%20%20%20%20%20%20%22Linear%20regression%20aims%20to%20find%20the%20'best%20fit'%20line%20through%20data%20points.%20This%20line%20is%20defined%20by%20weights%20(coefficients)%20and%20a%20bias%20term.%22%0A%20%20%20%20)%0A%0A%20%20%20%20%23%20Create%20sample%20data%0A%20%20%20%20X%2C%20y%20%3D%20make_regression(n_samples%3D100%2C%20n_features%3D1%2C%20noise%3D10%2C%20random_state%3D42)%0A%0A%20%20%20%20%23%20Fit%20sklearn%20linear%20regression%0A%20%20%20%20lin_reg%20%3D%20LinearRegression()%0A%20%20%20%20lin_reg.fit(X%2C%20y)%0A%0A%20%20%20%20%23%20Calculate%20predictions%0A%20%20%20%20y_pred%20%3D%20lin_reg.predict(X)%0A%0A%20%20%20%20if%20plt%20is%20not%20None%3A%0A%20%20%20%20%20%20%20%20%23%20Sort%20the%20data%20for%20a%20prettier%20line%20plot%0A%20%20%20%20%20%20%20%20sorted_idx%20%3D%20np.argsort(X%5B%3A%2C%200%5D)%0A%20%20%20%20%20%20%20%20X_sorted%20%3D%20X%5Bsorted_idx%5D%0A%20%20%20%20%20%20%20%20y_pred_sorted%20%3D%20y_pred%5Bsorted_idx%5D%0A%20%20%20%20%20%20%20%20y_sorted%20%3D%20y%5Bsorted_idx%5D%0A%0A%20%20%20%20%20%20%20%20%23%20Plot%0A%20%20%20%20%20%20%20%20fig%2C%20ax%20%3D%20plt.subplots(figsize%3D(10%2C%206))%0A%20%20%20%20%20%20%20%20ax.scatter(X%2C%20y%2C%20alpha%3D0.6%2C%20label%3D%22Data%20points%22)%0A%20%20%20%20%20%20%20%20ax.plot(X_sorted%2C%20y_pred_sorted%2C%20color%3D%22red%22%2C%20linewidth%3D2%2C%20label%3D%22Fitted%20line%22)%0A%20%20%20%20%20%20%20%20ax.set_xlabel(%22X%22)%0A%20%20%20%20%20%20%20%20ax.set_ylabel(%22y%22)%0A%20%20%20%20%20%20%20%20ax.legend()%0A%20%20%20%20%20%20%20%20ax.grid(True%2C%20alpha%3D0.3)%0A%20%20%20%20%20%20%20%20ax.set_title(%22Linear%20Regression%20Example%22)%0A%0A%20%20%20%20%20%20%20%20mo.display(fig)%0A%20%20%20%20else%3A%0A%20%20%20%20%20%20%20%20mo.notification(%22Skipping%20plot%3A%20matplotlib%20is%20not%20available.%22%2C%20kind%3D%22error%22)%0A%20%20%20%20return%20X%2C%20lin_reg%2C%20y%2C%20y_pred%0A%0A%0A%40app.cell%0Adef%20_(lin_reg%2C%20mo)%3A%0A%20%20%20%20mo.md(%22%23%23%20Mathematical%20Foundation%22)%0A%20%20%20%20mo.md(%22The%20linear%20regression%20model%20can%20be%20expressed%20as%3A%22)%0A%20%20%20%20mo.md(%22%24%24y%20%3D%20Xw%20%2B%20b%24%24%22)%0A%20%20%20%20mo.md(%22Where%3A%22)%0A%20%20%20%20mo.md(%22-%20%24y%24%20is%20the%20target%20variable%22)%0A%20%20%20%20mo.md(%22-%20%24X%24%20is%20the%20matrix%20of%20input%20features%22)%0A%20%20%20%20mo.md(%22-%20%24w%24%20is%20the%20vector%20of%20weights%22)%0A%20%20%20%20mo.md(%22-%20%24b%24%20is%20the%20bias%20term%22)%0A%0A%20%20%20%20mo.md(%22In%20our%20example%3A%22)%0A%20%20%20%20mo.md(f%22-%20Weights%20(w)%3A%20%7Blin_reg.coef_%5B0%5D%3A.2f%7D%22)%0A%20%20%20%20mo.md(f%22-%20Bias%20(b)%3A%20%7Blin_reg.intercept_%3A.2f%7D%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(mo%2C%20np%2C%20y%2C%20y_pred)%3A%0A%20%20%20%20mo.md(%22%23%23%20Loss%20Function%3A%20Mean%20Squared%20Error%20(MSE)%22)%0A%20%20%20%20mo.md(%0A%20%20%20%20%20%20%20%20%22The%20goal%20of%20linear%20regression%20is%20to%20minimize%20the%20error%20between%20predicted%20and%20actual%20values.%20The%20most%20common%20loss%20function%20is%20Mean%20Squared%20Error%3A%22%0A%20%20%20%20)%0A%20%20%20%20mo.md(%22%24%24%5C%5Ctext%7BMSE%7D%20%3D%20%5C%5Cfrac%7B1%7D%7Bn%7D%20%5C%5Csum_%7Bi%3D1%7D%5E%7Bn%7D%20(y_i%20-%20%5C%5Chat%7By%7D_i)%5E2%24%24%22)%0A%20%20%20%20mo.md(%22Where%3A%22)%0A%20%20%20%20mo.md(%22-%20%24y_i%24%20is%20the%20actual%20value%22)%0A%20%20%20%20mo.md(%22-%20%24%5C%5Chat%7By%7D_i%24%20is%20the%20predicted%20value%22)%0A%20%20%20%20mo.md(%22-%20%24n%24%20is%20the%20number%20of%20samples%22)%0A%0A%20%20%20%20%23%20Calculate%20MSE%0A%20%20%20%20mse%20%3D%20np.mean((y%20-%20y_pred)%20**%202)%0A%20%20%20%20mo.md(f%22In%20our%20example%2C%20the%20MSE%20is%20approximately%3A%20%7Bmse%3A.2f%7D%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(X%2C%20mo%2C%20np%2C%20y)%3A%0A%20%20%20%20mo.md(%22%23%23%20How%20Does%20It%20Work%3F%20Gradient%20Descent%22)%0A%20%20%20%20mo.md(%0A%20%20%20%20%20%20%20%20%22To%20minimize%20the%20MSE%2C%20we%20can%20use%20an%20optimization%20algorithm%20called%20gradient%20descent.%20This%20algorithm%20iteratively%20adjusts%20the%20weights%20and%20bias%20to%20reduce%20the%20loss%20function.%22%0A%20%20%20%20)%0A%0A%20%20%20%20%23%20Simple%20implementation%20of%20gradient%20descent%0A%20%20%20%20def%20gradient_descent(X%2C%20y%2C%20learning_rate%3D0.01%2C%20iterations%3D1000)%3A%0A%20%20%20%20%20%20%20%20m%20%3D%20len(X)%0A%20%20%20%20%20%20%20%20w%20%3D%20np.random.randn(1)%0A%20%20%20%20%20%20%20%20b%20%3D%200.0%0A%0A%20%20%20%20%20%20%20%20%23%20Reshape%20X%0A%20%20%20%20%20%20%20%20x_vec%20%3D%20X.ravel()%0A%20%20%20%20%20%20%20%20for%20i%20in%20range(iterations)%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20y_pred%20%3D%20x_vec%20*%20w%20%2B%20b%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20%23%20Compute%20gradients%0A%20%20%20%20%20%20%20%20%20%20%20%20dw%20%3D%20(-2%20%2F%20m)%20*%20np.sum(x_vec%20*%20(y%20-%20y_pred))%0A%20%20%20%20%20%20%20%20%20%20%20%20db%20%3D%20(-2%20%2F%20m)%20*%20np.sum(y%20-%20y_pred)%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20w%20-%3D%20learning_rate%20*%20dw%0A%20%20%20%20%20%20%20%20%20%20%20%20b%20-%3D%20learning_rate%20*%20db%0A%0A%20%20%20%20%20%20%20%20return%20w%2C%20b%0A%0A%20%20%20%20w_gd%2C%20b_gd%20%3D%20gradient_descent(X%2C%20y)%0A%20%20%20%20y_pred_gd%20%3D%20X.ravel()%20*%20w_gd%20%2B%20b_gd%0A%0A%20%20%20%20mse_gd%20%3D%20np.mean((y%20-%20y_pred_gd)%20**%202)%0A%0A%20%20%20%20mo.md(%22Using%20gradient%20descent%20(learning%20rate%3D0.01%2C%201000%20iterations)%3A%22)%0A%20%20%20%20mo.md(f%22-%20Weights%20(w)%3A%20%7Bw_gd%5B0%5D%3A.2f%7D%22)%0A%20%20%20%20mo.md(f%22-%20Bias%20(b)%3A%20%7Bb_gd%3A.2f%7D%22)%0A%20%20%20%20mo.md(f%22-%20MSE%3A%20%7Bmse_gd%3A.2f%7D%22)%0A%20%20%20%20return%20(y_pred_gd%2C)%0A%0A%0A%40app.cell%0Adef%20_(X%2C%20mo%2C%20np%2C%20plt%2C%20y%2C%20y_pred%2C%20y_pred_gd)%3A%0A%20%20%20%20mo.md(%22%23%23%20Visualizing%20the%20Process%22)%0A%20%20%20%20if%20plt%20is%20not%20None%3A%0A%20%20%20%20%20%20%20%20fig%2C%20ax%20%3D%20plt.subplots(figsize%3D(10%2C%206))%0A%20%20%20%20%20%20%20%20ax.scatter(X%2C%20y%2C%20alpha%3D0.6%2C%20label%3D%22Data%20points%22)%0A%0A%20%20%20%20%20%20%20%20%23%20For%20clean%20lines%2C%20sort%20the%20X%20for%20both%20predictions%0A%20%20%20%20%20%20%20%20sorted_idx%20%3D%20np.argsort(X%5B%3A%2C%200%5D)%0A%20%20%20%20%20%20%20%20X_sorted%20%3D%20X%5Bsorted_idx%5D%0A%20%20%20%20%20%20%20%20y_pred_sorted%20%3D%20y_pred%5Bsorted_idx%5D%0A%20%20%20%20%20%20%20%20y_pred_gd_sorted%20%3D%20y_pred_gd%5Bsorted_idx%5D%0A%0A%20%20%20%20%20%20%20%20ax.plot(X_sorted%2C%20y_pred_sorted%2C%20color%3D%22red%22%2C%20linewidth%3D2%2C%20label%3D%22Sklearn%20fit%22)%0A%20%20%20%20%20%20%20%20ax.plot(X_sorted%2C%20y_pred_gd_sorted%2C%20color%3D%22green%22%2C%20linewidth%3D2%2C%20linestyle%3D%22--%22%2C%20label%3D%22Gradient%20descent%22)%0A%20%20%20%20%20%20%20%20ax.set_xlabel(%22X%22)%0A%20%20%20%20%20%20%20%20ax.set_ylabel(%22y%22)%0A%20%20%20%20%20%20%20%20ax.legend()%0A%20%20%20%20%20%20%20%20ax.grid(True%2C%20alpha%3D0.3)%0A%20%20%20%20%20%20%20%20ax.set_title(%22Comparison%3A%20Sklearn%20vs%20Gradient%20Descent%22)%0A%20%20%20%20%20%20%20%20mo.display(fig)%0A%20%20%20%20else%3A%0A%20%20%20%20%20%20%20%20mo.notification(%22Skipping%20plot%3A%20matplotlib%20is%20not%20available.%22%2C%20kind%3D%22error%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(%22%23%23%20Key%20Takeaways%22)%0A%20%20%20%20mo.md(%221.%20Linear%20regression%20models%20the%20relationship%20between%20variables%20using%20a%20linear%20equation.%22)%0A%20%20%20%20mo.md(%222.%20The%20parameters%20(weights%20and%20bias)%20are%20learned%20by%20minimizing%20the%20loss%20function.%22)%0A%20%20%20%20mo.md(%223.%20Mean%20Squared%20Error%20(MSE)%20is%20a%20common%20loss%20function.%22)%0A%20%20%20%20mo.md(%224.%20Gradient%20descent%20is%20commonly%20used%20to%20optimize%20parameters.%22)%0A%20%20%20%20mo.md(%225.%20Even%20simple%20models%20like%20linear%20regression%20can%20be%20very%20effective!%22)%0A%0A%20%20%20%20mo.md(%22---%22)%0A%20%20%20%20mo.md(%22**Try%20modifying%20the%20data%2C%20learning%20rate%2C%20or%20number%20of%20iterations%20above%20to%20see%20how%20the%20model%20changes!**%22)%0A%20%20%20%20return%0A%0A%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20app.run()%0A
301
+ </marimo-code>
302
+
303
+ <marimo-code-hash hidden="">a84b1545db77625ca9cf9d6dc8c1e9c1</marimo-code-hash>
304
+ </body>
305
+ </html>
openenv_explainer_env.egg-info/PKG-INFO CHANGED
@@ -3,12 +3,22 @@ Name: openenv-explainer_env
3
  Version: 0.1.0
4
  Summary: Interactive Explainer OpenEnv
5
  Requires-Python: >=3.10
6
- Requires-Dist: openenv-core[core]>=0.2.2
7
  Requires-Dist: marimo>=0.10.0
8
  Requires-Dist: manim>=0.18.0
9
  Requires-Dist: wikipedia-api>=0.14.1
10
  Requires-Dist: huggingface-hub>=1.12.0
11
  Requires-Dist: httpx>=0.28.1
 
 
 
 
 
 
 
 
 
 
12
  Provides-Extra: dev
13
  Requires-Dist: pytest>=8.0.0; extra == "dev"
14
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
 
3
  Version: 0.1.0
4
  Summary: Interactive Explainer OpenEnv
5
  Requires-Python: >=3.10
6
+ Requires-Dist: openenv-core[core]>=0.2.3
7
  Requires-Dist: marimo>=0.10.0
8
  Requires-Dist: manim>=0.18.0
9
  Requires-Dist: wikipedia-api>=0.14.1
10
  Requires-Dist: huggingface-hub>=1.12.0
11
  Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: nbformat>=5.10.4
13
+ Requires-Dist: numpy
14
+ Requires-Dist: matplotlib
15
+ Requires-Dist: pandas
16
+ Requires-Dist: scipy
17
+ Requires-Dist: sympy
18
+ Requires-Dist: scikit-learn
19
+ Requires-Dist: networkx
20
+ Requires-Dist: plotly
21
+ Requires-Dist: trafilatura
22
  Provides-Extra: dev
23
  Requires-Dist: pytest>=8.0.0; extra == "dev"
24
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
openenv_explainer_env.egg-info/SOURCES.txt CHANGED
@@ -14,6 +14,10 @@ openenv_explainer_env.egg-info/dependency_links.txt
14
  openenv_explainer_env.egg-info/entry_points.txt
15
  openenv_explainer_env.egg-info/requires.txt
16
  openenv_explainer_env.egg-info/top_level.txt
 
 
 
 
17
  rewards/__init__.py
18
  rewards/exploration.py
19
  rewards/generation.py
 
14
  openenv_explainer_env.egg-info/entry_points.txt
15
  openenv_explainer_env.egg-info/requires.txt
16
  openenv_explainer_env.egg-info/top_level.txt
17
+ research/__init__.py
18
+ research/retrieval.py
19
+ research/router.py
20
+ research/types.py
21
  rewards/__init__.py
22
  rewards/exploration.py
23
  rewards/generation.py
openenv_explainer_env.egg-info/requires.txt CHANGED
@@ -1,9 +1,19 @@
1
- openenv-core[core]>=0.2.2
2
  marimo>=0.10.0
3
  manim>=0.18.0
4
  wikipedia-api>=0.14.1
5
  huggingface-hub>=1.12.0
6
  httpx>=0.28.1
 
 
 
 
 
 
 
 
 
 
7
 
8
  [dev]
9
  pytest>=8.0.0
 
1
+ openenv-core[core]>=0.2.3
2
  marimo>=0.10.0
3
  manim>=0.18.0
4
  wikipedia-api>=0.14.1
5
  huggingface-hub>=1.12.0
6
  httpx>=0.28.1
7
+ nbformat>=5.10.4
8
+ numpy
9
+ matplotlib
10
+ pandas
11
+ scipy
12
+ sympy
13
+ scikit-learn
14
+ networkx
15
+ plotly
16
+ trafilatura
17
 
18
  [dev]
19
  pytest>=8.0.0
pyproject.toml CHANGED
@@ -8,12 +8,22 @@ version = "0.1.0"
8
  description = "Interactive Explainer OpenEnv"
9
  requires-python = ">=3.10"
10
  dependencies = [
11
- "openenv-core[core]>=0.2.2",
12
  "marimo>=0.10.0",
13
  "manim>=0.18.0",
14
  "wikipedia-api>=0.14.1",
15
  "huggingface-hub>=1.12.0",
16
  "httpx>=0.28.1",
 
 
 
 
 
 
 
 
 
 
17
  ]
18
 
19
  [project.optional-dependencies]
@@ -27,8 +37,8 @@ server = "explainer_env.server.app:main"
27
 
28
  [tool.setuptools]
29
  include-package-data = true
30
- packages = ["explainer_env", "explainer_env.server", "explainer_env.rewards"]
31
- package-dir = { "explainer_env" = ".", "explainer_env.server" = "server", "explainer_env.rewards" = "rewards" }
32
 
33
  [dependency-groups]
34
  dev = []
 
8
  description = "Interactive Explainer OpenEnv"
9
  requires-python = ">=3.10"
10
  dependencies = [
11
+ "openenv-core[core]>=0.2.3",
12
  "marimo>=0.10.0",
13
  "manim>=0.18.0",
14
  "wikipedia-api>=0.14.1",
15
  "huggingface-hub>=1.12.0",
16
  "httpx>=0.28.1",
17
+ "nbformat>=5.10.4",
18
+ "numpy",
19
+ "matplotlib",
20
+ "pandas",
21
+ "scipy",
22
+ "sympy",
23
+ "scikit-learn",
24
+ "networkx",
25
+ "plotly",
26
+ "trafilatura",
27
  ]
28
 
29
  [project.optional-dependencies]
 
37
 
38
  [tool.setuptools]
39
  include-package-data = true
40
+ packages = ["explainer_env", "explainer_env.server", "explainer_env.rewards", "explainer_env.research"]
41
+ package-dir = { "explainer_env" = ".", "explainer_env.server" = "server", "explainer_env.rewards" = "rewards", "explainer_env.research" = "research" }
42
 
43
  [dependency-groups]
44
  dev = []
research/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Research tools for the explainer environment."""
2
+
3
+ from .router import AVAILABLE_TOOLS, run_research_tool
4
+ from .types import ResearchChunk, ResearchResult
5
+
6
+ __all__ = [
7
+ "AVAILABLE_TOOLS",
8
+ "ResearchChunk",
9
+ "ResearchResult",
10
+ "run_research_tool",
11
+ ]
research/retrieval.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small retrieval helpers: tokenization, BM25, chunking, optional embeddings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import re
8
+ from collections import Counter
9
+
10
+ from .types import ResearchChunk
11
+
12
+ SECTION_MAX_CHARS = 900
13
+ MAX_RETURNED_CHUNKS = 5
14
+ BM25_CANDIDATES_FOR_EMBEDDINGS = 12
15
+
16
+ _BM25_K1 = 1.5
17
+ _BM25_B = 0.75
18
+
19
+ _STOP_WORDS = frozenset({
20
+ "the",
21
+ "a",
22
+ "an",
23
+ "is",
24
+ "are",
25
+ "was",
26
+ "were",
27
+ "be",
28
+ "been",
29
+ "being",
30
+ "have",
31
+ "has",
32
+ "had",
33
+ "do",
34
+ "does",
35
+ "did",
36
+ "will",
37
+ "would",
38
+ "could",
39
+ "should",
40
+ "to",
41
+ "of",
42
+ "in",
43
+ "for",
44
+ "on",
45
+ "with",
46
+ "at",
47
+ "by",
48
+ "from",
49
+ "as",
50
+ "and",
51
+ "but",
52
+ "or",
53
+ "this",
54
+ "that",
55
+ "these",
56
+ "those",
57
+ "it",
58
+ "its",
59
+ })
60
+
61
+
62
+ def tokenize(text: str) -> list[str]:
63
+ """Lowercase alphanumeric tokenization, stop words removed."""
64
+ return [
65
+ word
66
+ for word in re.findall(r"\w+", text.lower())
67
+ if word not in _STOP_WORDS and len(word) > 1
68
+ ]
69
+
70
+
71
+ def trim_text(text: str, max_chars: int = SECTION_MAX_CHARS) -> str:
72
+ text = re.sub(r"\s+", " ", text).strip()
73
+ return text[:max_chars].strip()
74
+
75
+
76
+ def chunk_markdown(text: str, fallback_title: str) -> list[tuple[str, str]]:
77
+ """Split markdown-ish text into titled chunks."""
78
+ chunks: list[tuple[str, str]] = []
79
+ heading = fallback_title
80
+ lines: list[str] = []
81
+
82
+ for line in text.splitlines():
83
+ if line.startswith("#"):
84
+ body = "\n".join(lines).strip()
85
+ if body:
86
+ chunks.append((heading, body))
87
+ heading = line.lstrip("#").strip() or fallback_title
88
+ lines = []
89
+ else:
90
+ lines.append(line)
91
+
92
+ body = "\n".join(lines).strip()
93
+ if body:
94
+ chunks.append((heading, body))
95
+ return chunks
96
+
97
+
98
+ def bm25_rank(query: str, chunks: list[ResearchChunk], top_k: int) -> list[ResearchChunk]:
99
+ """Rank chunks against query using BM25."""
100
+ if not chunks:
101
+ return []
102
+
103
+ query_terms = tokenize(query)
104
+ if not query_terms:
105
+ ranked = chunks[:top_k]
106
+ for idx, chunk in enumerate(ranked, start=1):
107
+ chunk.rank = idx
108
+ return ranked
109
+
110
+ doc_tokens = [tokenize(f"{chunk.title} {chunk.text}") for chunk in chunks]
111
+ doc_lengths = [len(tokens) for tokens in doc_tokens]
112
+ avgdl = sum(doc_lengths) / max(len(doc_lengths), 1)
113
+ n_docs = len(chunks)
114
+
115
+ df = {
116
+ term: sum(1 for tokens in doc_tokens if term in tokens)
117
+ for term in set(query_terms)
118
+ }
119
+
120
+ scored: list[ResearchChunk] = []
121
+ for idx, chunk in enumerate(chunks):
122
+ tf_counts = Counter(doc_tokens[idx])
123
+ dl = doc_lengths[idx]
124
+ score = 0.0
125
+ for term in query_terms:
126
+ if df.get(term, 0) == 0:
127
+ continue
128
+ idf = math.log((n_docs - df[term] + 0.5) / (df[term] + 0.5) + 1.0)
129
+ tf = tf_counts.get(term, 0)
130
+ score += idf * tf * (_BM25_K1 + 1) / (
131
+ tf + _BM25_K1 * (1 - _BM25_B + _BM25_B * dl / max(avgdl, 1))
132
+ )
133
+ chunk.score = score
134
+ scored.append(chunk)
135
+
136
+ scored.sort(key=lambda chunk: chunk.score, reverse=True)
137
+ ranked = _maybe_embedding_rerank(query, scored[:BM25_CANDIDATES_FOR_EMBEDDINGS])
138
+ ranked = ranked[:top_k]
139
+ for idx, chunk in enumerate(ranked, start=1):
140
+ chunk.rank = idx
141
+ return ranked
142
+
143
+
144
+ def _maybe_embedding_rerank(query: str, chunks: list[ResearchChunk]) -> list[ResearchChunk]:
145
+ """Optionally rerank a small BM25 shortlist with a tiny local embedding model."""
146
+ if os.getenv("EMBEDDINGS_ENABLED", "").lower() not in {"1", "true", "yes"}:
147
+ return chunks
148
+
149
+ try:
150
+ from fastembed import TextEmbedding
151
+ except Exception:
152
+ return chunks
153
+
154
+ try:
155
+ model_name = os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5")
156
+ model = TextEmbedding(model_name=model_name)
157
+ vectors = list(model.embed([query] + [chunk.text for chunk in chunks]))
158
+ except Exception:
159
+ return chunks
160
+
161
+ if len(vectors) != len(chunks) + 1:
162
+ return chunks
163
+
164
+ query_vec = vectors[0]
165
+ rescored: list[ResearchChunk] = []
166
+ for chunk, vec in zip(chunks, vectors[1:]):
167
+ chunk.score = _cosine(query_vec, vec)
168
+ rescored.append(chunk)
169
+ rescored.sort(key=lambda chunk: chunk.score, reverse=True)
170
+ return rescored
171
+
172
+
173
+ def _cosine(a, b) -> float:
174
+ numerator = sum(float(x) * float(y) for x, y in zip(a, b))
175
+ a_norm = math.sqrt(sum(float(x) * float(x) for x in a))
176
+ b_norm = math.sqrt(sum(float(y) * float(y) for y in b))
177
+ if a_norm == 0 or b_norm == 0:
178
+ return 0.0
179
+ return numerator / (a_norm * b_norm)
research/router.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit research tools used by the explore phase."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import html
7
+ import re
8
+ import xml.etree.ElementTree as ET
9
+ from typing import Callable, Awaitable
10
+
11
+ import httpx
12
+ import wikipediaapi
13
+
14
+ try:
15
+ from .. import constants as _constants
16
+ except ImportError: # pragma: no cover - supports direct test execution
17
+ import constants as _constants
18
+
19
+ from .retrieval import bm25_rank, chunk_markdown, trim_text
20
+ from .types import ResearchChunk, ResearchResult
21
+
22
+ AVAILABLE_TOOLS = _constants.AVAILABLE_TOOLS
23
+
24
+ MAX_SOURCE_RESULTS = 5
25
+ MAX_RETURNED_CHUNKS = 5
26
+
27
+
28
+ async def run_research_tool(tool: str, query: str, intent: str = "") -> ResearchResult:
29
+ """Run a named research tool and return structured chunks."""
30
+ tool = tool.strip()
31
+ query = query.strip()
32
+ if tool not in _TOOL_RUNNERS:
33
+ return ResearchResult(tool=tool or "(missing)", query=query, error="Unknown research tool")
34
+ if not query:
35
+ return ResearchResult(tool=tool, query=query, error="Empty query")
36
+ return await _TOOL_RUNNERS[tool](query, intent)
37
+
38
+
39
+ async def search_wikipedia(query: str, intent: str = "") -> ResearchResult:
40
+ try:
41
+ wiki = wikipediaapi.AsyncWikipedia(
42
+ user_agent="ExplainerEnv/1.0 (hackathon project)",
43
+ language="en",
44
+ )
45
+ search_results = await wiki.search(query, limit=MAX_SOURCE_RESULTS)
46
+ titles = list(search_results.pages) if search_results and search_results.pages else []
47
+ chunks: list[ResearchChunk] = []
48
+
49
+ for title in titles:
50
+ page = wiki.page(title)
51
+ if not await page.exists():
52
+ continue
53
+ summary = await page.summary
54
+ sections = await page.sections
55
+ docs: list[tuple[str, str]] = []
56
+ if summary:
57
+ docs.append((title, summary))
58
+ docs.extend(_flatten_wikipedia_sections(sections))
59
+
60
+ for section_title, text in docs:
61
+ snippet = trim_text(text)
62
+ if snippet:
63
+ chunks.append(
64
+ ResearchChunk(
65
+ source="wikipedia",
66
+ tool="search_wikipedia",
67
+ title=f"{title} - {section_title}",
68
+ url=f"https://en.wikipedia.org/wiki/{title.replace(' ', '_')}",
69
+ text=snippet,
70
+ metadata={"page": title},
71
+ )
72
+ )
73
+
74
+ ranked = bm25_rank(f"{query} {intent}", chunks, MAX_RETURNED_CHUNKS)
75
+ return ResearchResult("search_wikipedia", query, ranked, raw_count=len(chunks))
76
+ except Exception as exc:
77
+ return ResearchResult("search_wikipedia", query, error=str(exc))
78
+
79
+
80
+ async def search_hf_papers(query: str, intent: str = "") -> ResearchResult:
81
+ try:
82
+ async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
83
+ resp = await client.get(
84
+ "https://huggingface.co/api/papers/search",
85
+ params={"q": query, "limit": MAX_SOURCE_RESULTS},
86
+ headers={"User-Agent": "ExplainerEnv/1.0"},
87
+ )
88
+ resp.raise_for_status()
89
+ papers = resp.json() or []
90
+ chunks: list[ResearchChunk] = []
91
+
92
+ for paper in papers[:MAX_SOURCE_RESULTS]:
93
+ paper_id = paper.get("id", "")
94
+ title = paper.get("title", "Untitled")
95
+ url = f"https://huggingface.co/papers/{paper_id}" if paper_id else ""
96
+ text_chunks = [("Abstract", paper.get("summary", ""))]
97
+
98
+ if paper_id:
99
+ md_resp = await client.get(
100
+ f"https://huggingface.co/papers/{paper_id}.md",
101
+ headers={"User-Agent": "ExplainerEnv/1.0"},
102
+ )
103
+ if md_resp.status_code == 200 and md_resp.text.strip():
104
+ text_chunks = chunk_markdown(md_resp.text, "Abstract")
105
+
106
+ for section_title, text in text_chunks:
107
+ snippet = trim_text(text)
108
+ if snippet:
109
+ chunks.append(
110
+ ResearchChunk(
111
+ source="hf_papers",
112
+ tool="search_hf_papers",
113
+ title=f"{title} - {section_title}",
114
+ url=url,
115
+ text=snippet,
116
+ metadata={"paper_id": paper_id},
117
+ )
118
+ )
119
+
120
+ ranked = bm25_rank(f"{query} {intent}", chunks, MAX_RETURNED_CHUNKS)
121
+ return ResearchResult("search_hf_papers", query, ranked, raw_count=len(chunks))
122
+ except Exception as exc:
123
+ return ResearchResult("search_hf_papers", query, error=str(exc))
124
+
125
+
126
+ async def search_arxiv(query: str, intent: str = "") -> ResearchResult:
127
+ try:
128
+ async with httpx.AsyncClient(timeout=15.0) as client:
129
+ resp = await client.get(
130
+ "https://export.arxiv.org/api/query",
131
+ params={
132
+ "search_query": f"all:{query}",
133
+ "start": 0,
134
+ "max_results": MAX_SOURCE_RESULTS,
135
+ "sortBy": "relevance",
136
+ "sortOrder": "descending",
137
+ },
138
+ headers={"User-Agent": "ExplainerEnv/1.0"},
139
+ )
140
+ resp.raise_for_status()
141
+
142
+ root = ET.fromstring(resp.text)
143
+ ns = {"atom": "http://www.w3.org/2005/Atom", "arxiv": "http://arxiv.org/schemas/atom"}
144
+ chunks: list[ResearchChunk] = []
145
+ for entry in root.findall("atom:entry", ns):
146
+ title = _xml_text(entry, "atom:title", ns) or "Untitled"
147
+ summary = _xml_text(entry, "atom:summary", ns)
148
+ url = _xml_text(entry, "atom:id", ns)
149
+ published = _xml_text(entry, "atom:published", ns)
150
+ authors = [
151
+ _xml_text(author, "atom:name", ns)
152
+ for author in entry.findall("atom:author", ns)
153
+ ]
154
+ categories = [
155
+ cat.attrib.get("term", "")
156
+ for cat in entry.findall("atom:category", ns)
157
+ if cat.attrib.get("term")
158
+ ]
159
+ snippet = trim_text(summary)
160
+ if snippet:
161
+ chunks.append(
162
+ ResearchChunk(
163
+ source="arxiv",
164
+ tool="search_arxiv",
165
+ title=html.unescape(title),
166
+ url=url,
167
+ text=html.unescape(snippet),
168
+ metadata={
169
+ "published": published,
170
+ "authors": [a for a in authors if a],
171
+ "categories": categories,
172
+ },
173
+ )
174
+ )
175
+
176
+ ranked = bm25_rank(f"{query} {intent}", chunks, MAX_RETURNED_CHUNKS)
177
+ return ResearchResult("search_arxiv", query, ranked, raw_count=len(chunks))
178
+ except Exception as exc:
179
+ return ResearchResult("search_arxiv", query, error=str(exc))
180
+
181
+
182
+ async def search_scholar(query: str, intent: str = "") -> ResearchResult:
183
+ try:
184
+ async with httpx.AsyncClient(timeout=15.0) as client:
185
+ resp = await client.get(
186
+ "https://api.semanticscholar.org/graph/v1/paper/search",
187
+ params={
188
+ "query": query,
189
+ "limit": MAX_SOURCE_RESULTS,
190
+ "fields": "title,abstract,url,year,citationCount,venue,authors",
191
+ },
192
+ headers={"User-Agent": "ExplainerEnv/1.0"},
193
+ )
194
+ resp.raise_for_status()
195
+ papers = resp.json().get("data", [])
196
+
197
+ chunks: list[ResearchChunk] = []
198
+ for paper in papers:
199
+ abstract = paper.get("abstract") or ""
200
+ snippet = trim_text(abstract)
201
+ if not snippet:
202
+ continue
203
+ chunks.append(
204
+ ResearchChunk(
205
+ source="semantic_scholar",
206
+ tool="search_scholar",
207
+ title=paper.get("title", "Untitled"),
208
+ url=paper.get("url", ""),
209
+ text=snippet,
210
+ metadata={
211
+ "year": paper.get("year"),
212
+ "venue": paper.get("venue"),
213
+ "citation_count": paper.get("citationCount", 0),
214
+ },
215
+ )
216
+ )
217
+
218
+ ranked = bm25_rank(f"{query} {intent}", chunks, MAX_RETURNED_CHUNKS)
219
+ return ResearchResult("search_scholar", query, ranked, raw_count=len(chunks))
220
+ except Exception as exc:
221
+ return ResearchResult("search_scholar", query, error=str(exc))
222
+
223
+
224
+ async def fetch_docs(query: str, intent: str = "") -> ResearchResult:
225
+ urls = _select_doc_urls(query)
226
+ if not urls:
227
+ return ResearchResult("fetch_docs", query, error="No allowed documentation target matched query")
228
+
229
+ chunks: list[ResearchChunk] = []
230
+ async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
231
+ for title, url in urls[:MAX_SOURCE_RESULTS]:
232
+ try:
233
+ resp = await client.get(url, headers={"User-Agent": "ExplainerEnv/1.0"})
234
+ resp.raise_for_status()
235
+ text = _extract_page_text(resp.text)
236
+ except Exception:
237
+ continue
238
+ for section_title, body in chunk_markdown(text, title):
239
+ snippet = trim_text(body)
240
+ if snippet:
241
+ chunks.append(
242
+ ResearchChunk(
243
+ source="docs",
244
+ tool="fetch_docs",
245
+ title=f"{title} - {section_title}",
246
+ url=url,
247
+ text=snippet,
248
+ metadata={"doc": title},
249
+ )
250
+ )
251
+
252
+ ranked = bm25_rank(f"{query} {intent}", chunks, MAX_RETURNED_CHUNKS)
253
+ return ResearchResult("fetch_docs", query, ranked, raw_count=len(chunks))
254
+
255
+
256
+ async def search_hf_hub(query: str, intent: str = "") -> ResearchResult:
257
+ try:
258
+ from huggingface_hub import HfApi
259
+ except Exception as exc:
260
+ return ResearchResult("search_hf_hub", query, error=f"huggingface_hub unavailable: {exc}")
261
+
262
+ def _load() -> list[ResearchChunk]:
263
+ api = HfApi()
264
+ chunks: list[ResearchChunk] = []
265
+
266
+ for model in api.list_models(search=query, limit=2):
267
+ text = " ".join(str(x) for x in [model.modelId, model.pipeline_tag, model.tags] if x)
268
+ chunks.append(
269
+ ResearchChunk(
270
+ source="hf_hub_model",
271
+ tool="search_hf_hub",
272
+ title=model.modelId,
273
+ url=f"https://huggingface.co/{model.modelId}",
274
+ text=trim_text(text),
275
+ metadata={"downloads": getattr(model, "downloads", None)},
276
+ )
277
+ )
278
+
279
+ for dataset in api.list_datasets(search=query, limit=2):
280
+ dataset_id = getattr(dataset, "id", "")
281
+ text = " ".join(str(x) for x in [dataset_id, getattr(dataset, "tags", None)] if x)
282
+ chunks.append(
283
+ ResearchChunk(
284
+ source="hf_hub_dataset",
285
+ tool="search_hf_hub",
286
+ title=dataset_id,
287
+ url=f"https://huggingface.co/datasets/{dataset_id}",
288
+ text=trim_text(text),
289
+ metadata={"downloads": getattr(dataset, "downloads", None)},
290
+ )
291
+ )
292
+ return chunks
293
+
294
+ try:
295
+ chunks = await asyncio.to_thread(_load)
296
+ ranked = bm25_rank(f"{query} {intent}", chunks, MAX_RETURNED_CHUNKS)
297
+ return ResearchResult("search_hf_hub", query, ranked, raw_count=len(chunks))
298
+ except Exception as exc:
299
+ return ResearchResult("search_hf_hub", query, error=str(exc))
300
+
301
+
302
+ _TOOL_RUNNERS: dict[str, Callable[[str, str], Awaitable[ResearchResult]]] = {
303
+ "search_wikipedia": search_wikipedia,
304
+ "search_hf_papers": search_hf_papers,
305
+ "search_arxiv": search_arxiv,
306
+ "search_scholar": search_scholar,
307
+ "fetch_docs": fetch_docs,
308
+ "search_hf_hub": search_hf_hub,
309
+ }
310
+
311
+ _SKIP_SECTIONS = frozenset({
312
+ "references",
313
+ "external links",
314
+ "see also",
315
+ "further reading",
316
+ "notes",
317
+ "citations",
318
+ "bibliography",
319
+ "sources",
320
+ })
321
+
322
+ _DOC_URLS = {
323
+ "marimo": [
324
+ ("marimo CLI", "https://docs.marimo.io/cli/"),
325
+ ("marimo lint rules", "https://docs.marimo.io/guides/lint_rules/"),
326
+ ],
327
+ "manim": [
328
+ ("Manim quickstart", "https://docs.manim.community/en/stable/tutorials/quickstart.html"),
329
+ ("Manim reference", "https://docs.manim.community/en/stable/reference.html"),
330
+ ],
331
+ "numpy": [("NumPy user guide", "https://numpy.org/doc/stable/user/")],
332
+ "scipy": [("SciPy user guide", "https://docs.scipy.org/doc/scipy/tutorial/")],
333
+ "sklearn": [("scikit-learn user guide", "https://scikit-learn.org/stable/user_guide.html")],
334
+ "pandas": [("pandas user guide", "https://pandas.pydata.org/docs/user_guide/")],
335
+ "pytorch": [("PyTorch docs", "https://pytorch.org/docs/stable/index.html")],
336
+ "plotly": [("Plotly Python docs", "https://plotly.com/python/")],
337
+ }
338
+
339
+
340
+ def _flatten_wikipedia_sections(sections, max_depth: int = 2, depth: int = 0) -> list[tuple[str, str]]:
341
+ result: list[tuple[str, str]] = []
342
+ for section in sections:
343
+ if section.title.lower() in _SKIP_SECTIONS:
344
+ continue
345
+ if section.text.strip():
346
+ result.append((section.title, section.text.strip()))
347
+ if depth < max_depth and section.sections:
348
+ result.extend(_flatten_wikipedia_sections(section.sections, max_depth, depth + 1))
349
+ return result
350
+
351
+
352
+ def _xml_text(node, path: str, ns: dict[str, str]) -> str:
353
+ found = node.find(path, ns)
354
+ return found.text.strip() if found is not None and found.text else ""
355
+
356
+
357
+ def _select_doc_urls(query: str) -> list[tuple[str, str]]:
358
+ lower = query.lower()
359
+ selected: list[tuple[str, str]] = []
360
+ for key, urls in _DOC_URLS.items():
361
+ if key in lower or key.replace("sklearn", "scikit-learn") in lower:
362
+ selected.extend(urls)
363
+ if not selected and any(term in lower for term in ("notebook", "animation", "plot", "chart", "lint")):
364
+ selected.extend(_DOC_URLS["marimo"])
365
+ selected.extend(_DOC_URLS["manim"])
366
+ selected.extend(_DOC_URLS["plotly"])
367
+ return selected
368
+
369
+
370
+ def _extract_page_text(markup: str) -> str:
371
+ try:
372
+ import trafilatura
373
+
374
+ extracted = trafilatura.extract(markup, output_format="markdown")
375
+ if extracted:
376
+ return extracted
377
+ except Exception:
378
+ pass
379
+ text = re.sub(r"<(script|style).*?</\1>", " ", markup, flags=re.DOTALL | re.IGNORECASE)
380
+ text = re.sub(r"<[^>]+>", " ", text)
381
+ return html.unescape(re.sub(r"\s+", " ", text))
research/types.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed research results shared by exploration tools and rewards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class ResearchChunk:
11
+ """A ranked passage returned by a research tool."""
12
+
13
+ source: str
14
+ tool: str
15
+ title: str
16
+ url: str
17
+ text: str
18
+ score: float = 0.0
19
+ rank: int = 0
20
+ metadata: dict[str, Any] = field(default_factory=dict)
21
+
22
+ @property
23
+ def snippet(self) -> str:
24
+ return self.text
25
+
26
+
27
+ @dataclass
28
+ class ResearchResult:
29
+ """Structured response from a research tool."""
30
+
31
+ tool: str
32
+ query: str
33
+ chunks: list[ResearchChunk] = field(default_factory=list)
34
+ error: str = ""
35
+ raw_count: int = 0
36
+
37
+ @property
38
+ def ok(self) -> bool:
39
+ return not self.error and bool(self.chunks)
40
+
41
+ @property
42
+ def text(self) -> str:
43
+ return "\n\n".join(chunk.text for chunk in self.chunks)
44
+
45
+ @property
46
+ def sources(self) -> set[str]:
47
+ return {chunk.source for chunk in self.chunks}
48
+
49
+ def render(self) -> str:
50
+ """Render compact context for the agent."""
51
+ if self.error:
52
+ return f"{self.tool} error: {self.error}"
53
+ if not self.chunks:
54
+ return f"No results for {self.tool}: {self.query}"
55
+
56
+ parts = []
57
+ for chunk in self.chunks:
58
+ url = f"\nURL: {chunk.url}" if chunk.url else ""
59
+ parts.append(
60
+ f"[{chunk.rank}] {chunk.source}: {chunk.title}{url}\n{chunk.text}"
61
+ )
62
+ return "\n\n---\n\n".join(parts)
rewards/README.md CHANGED
@@ -1,107 +1,112 @@
1
  # Rewards
2
 
3
- Multi-component reward system for the two-phase explore generate episode.
4
 
5
  ## Episode Flow
6
 
7
  ```
8
- reset() [explore × 0..3] generate × 1 done
9
  ```
10
 
11
- Each step returns a per-step reward. The agent learns both *what* to explore and *when to stop*.
12
 
13
  ## Exploration Rewards (`exploration.py`)
14
 
15
- Per-step reward for each `explore` action. Gated by information need once the agent has enough info, further exploration yields diminishing returns.
16
 
17
  | Component | Weight | Range | Description |
18
  |---|---|---|---|
19
- | `query_relevance` | 0.40 | 01 | Topic + keyword overlap with search query |
20
- | `result_novelty` | 0.30 | 01 | New words vs. already-seen content |
21
- | `research_breadth` | 0.10 | 01 | Number of sources gathered (target >= 2) |
22
- | `content_sufficiency` | 0.20 | 01 | Keyword coverage across task + research (gates reward) |
23
- | `step_cost` | -0.05 | flat | Per-step penalty exploration must justify itself |
 
 
24
 
25
- **Gating mechanism**: `info_need = 1 - sufficiency`. Raw reward is scaled by `0.3 + 0.7 * info_need`, so high sufficiency low reward for more exploration. This teaches the agent to stop when it has enough.
26
 
27
  ## Generation Rewards (`generation.py`)
28
 
29
- Single reward on the `generate` action that ends the episode.
 
 
 
 
 
 
 
 
 
 
30
 
31
  | Component | Weight | Range | Description |
32
  |---|---|---|---|
33
- | `code_valid` | 0.15 | 0/1 | AST parses without errors |
34
- | `code_runs` | 0.15 | 0/1 | Sandbox execution succeeds (marimo export / manim render) |
35
- | `coverage` | 0.15 | 01 | Fraction of task keywords in generated code |
36
- | `format_match` | 0.10 | 0.3/1.0 | Chosen format matches task's preferred format (1.0 if task has no preference) |
37
- | `structure` | 0.15* | 01 | Structural quality (cells/scenes, UI elements, viz) |
38
- | `narration` | 0.10* | 0–1 | Narration quality (manim only; words, scene markers) |
39
- | `context_usage` | 0.20 | 0–1 | Code references terms from exploration research |
40
 
41
- *For marimo format, narration weight (0.10) is redistributed to structure ( 0.25 total).
42
 
43
- **Skip penalty**: Generating without any exploration incurs -0.1 penalty.
44
 
45
- ## Search Sources (`sources.py`)
 
 
 
 
46
 
47
- All search calls are **async** (httpx + wikipediaapi.AsyncWikipedia). Content is retrieved at section/chunk level and ranked using **BM25** to surface the most relevant parts.
48
 
49
- | Source | Library | Use Case | Retrieval |
50
- |---|---|---|---|
51
- | HuggingFace Papers | httpx → `huggingface.co/api/papers/search` + `papers/{id}.md` | ML/AI topics (semantic search) | Search → top paper → read markdown → BM25 chunk ranking |
52
- | Wikipedia | `wikipediaapi.AsyncWikipedia` | Math, algorithms, general topics | Search → top page → section tree → BM25 section ranking |
 
 
 
53
 
54
- **Routing**: ML-related queries (detected by keyword heuristic) HF Papers. Everything else → Wikipedia. Agent can override with prefix: `hf: query` or `wiki: query`. No explicit routing reward — bad routing leads to weak content → low novelty/relevance naturally.
55
 
56
- **Top 1 result** by default from each source, with top-3 BM25-ranked sections/chunks returned.
57
 
58
- ## Sandbox (`sandbox.py`)
59
 
60
- | Check | Tool | Timeout |
61
- |---|---|---|
62
- | `ast_parses` | Python `ast.parse` | — |
63
- | `run_marimo` | `marimo export html` | 15s |
64
- | `run_manim` | `manim render -ql` | 30s |
 
 
 
65
 
66
- ## LLM-as-Judge (`llm_judge.py`)
 
 
67
 
68
- **Eval-only** not used in the training loop (too slow, non-deterministic for RL reward signals).
 
 
 
 
 
 
 
69
 
70
- ### What it scores
71
 
72
- 5 dimensions on a 1-10 scale, normalized to 0-1:
73
 
74
- | Dimension | Description |
75
- |---|---|
76
- | Clarity | Is the concept explained clearly for the target tier? |
77
- | Accuracy | Is the content technically correct? |
78
- | Engagement | Does the code create an engaging, interactive experience? |
79
- | Completeness | Does it cover the key aspects of the topic? |
80
- | Appropriateness | Is the depth appropriate for the audience tier? |
81
-
82
- ### Configuration
83
-
84
- Set environment variables:
85
- - `JUDGE_API_URL` (required) — OpenAI-compatible endpoint (e.g. vLLM, ollama, OpenAI)
86
- - `JUDGE_API_KEY` (optional) — Bearer token for the API
87
- - `JUDGE_MODEL` (optional, default: `gpt-4o-mini`) — Model to use for judging
88
-
89
- ### Usage
90
-
91
- ```python
92
- from rewards.llm_judge import judge_explainability, is_available
93
-
94
- if is_available():
95
- score, details = judge_explainability(
96
- code="import marimo as mo\n...",
97
- topic="Linear Regression",
98
- tier="beginner",
99
- fmt="marimo",
100
- )
101
- print(f"Explainability score: {score:.2f}")
102
- print(f"Rationale: {details.get('rationale', '')}")
103
- ```
104
 
105
- ### What's used during training instead
 
 
 
 
 
106
 
107
- During GRPO training, the 12 heuristic reward components above provide the learning signal. They are deterministic, fast (<1ms per step), and decomposable for debugging. The LLM-as-judge is reserved for final evaluation and human-interpretable quality assessment.
 
1
  # Rewards
2
 
3
+ Multi-component reward system for the explore -> generate -> repair episode.
4
 
5
  ## Episode Flow
6
 
7
  ```
8
+ reset() --> [explore x 0..3] --> generate x 1 --> [repair x 0..1] --> done
9
  ```
10
 
11
+ Each step returns a per-step reward. The agent learns what tool to use, what to retrieve, when to stop exploring, and how to repair broken artifacts.
12
 
13
  ## Exploration Rewards (`exploration.py`)
14
 
15
+ Per-step reward for each `explore` action. Gated by information need -- once the agent has enough info, further exploration yields diminishing returns.
16
 
17
  | Component | Weight | Range | Description |
18
  |---|---|---|---|
19
+ | `tool_choice` | 0.15 | 0-1 | Tool fits task difficulty and query intent |
20
+ | `query_relevance` | 0.20 | 0-1 | Topic, keyword, and intent overlap |
21
+ | `source_quality` | 0.20 | 0-1 | Retrieved chunks have useful metadata, content, and relevance |
22
+ | `coverage_delta` | 0.20 | 0-1 | Newly covered missing concepts/keywords |
23
+ | `result_novelty` | 0.15 | 0-1 | New normalized terms vs. previous context |
24
+ | `diversity` | 0.10 | 0-1 | Useful new source/tool diversity |
25
+ | `step_cost` | -0.05 | flat | Per-step penalty -- exploration must justify itself |
26
 
27
+ **Gating mechanism**: `info_need = 1 - sufficiency`. Raw reward is scaled by `0.3 + 0.7 * info_need`, so high sufficiency -> low reward for more exploration. This teaches the agent to stop when it has enough.
28
 
29
  ## Generation Rewards (`generation.py`)
30
 
31
+ Reward on `generate` and `repair` actions. Uses **multiplicative gates** instead of additive weights for code validity.
32
+
33
+ ### Gates (multiplicative)
34
+
35
+ | Condition | Effect |
36
+ |---|---|
37
+ | Code doesn't parse (AST fails) | total = 0 |
38
+ | Code doesn't execute | total = quality * 0.4 |
39
+ | Code executes successfully | total = quality * 1.0 |
40
+
41
+ ### Quality components
42
 
43
  | Component | Weight | Range | Description |
44
  |---|---|---|---|
45
+ | `coverage` | 0.20 | 0-1 | Fraction of task keywords in generated code |
46
+ | `format_match` | 0.10 | 0.3/1.0 | Chosen format matches task's preferred format |
47
+ | `structure` | 0.20* | 0-1 | Structural quality (cells/scenes, UI, viz, `marimo check`) |
48
+ | `narration` | 0.15* | 0-1 | Narration quality (manim only; words, scene markers) |
49
+ | `context_usage` | 0.35 | 0-1 | Code references terms from exploration research |
 
 
50
 
51
+ *For marimo format, narration weight (0.15) is redistributed to structure (-> 0.35 total).
52
 
53
+ ### Marimo structure scoring
54
 
55
+ Additive scoring for good patterns:
56
+ - `import marimo` / `marimo.App()` / `@app.cell` count
57
+ - UI elements (`mo.ui.*`, `mo.md(`, etc.)
58
+ - Visualization libraries (`matplotlib`, `plotly`, etc.)
59
+ - Tier-appropriate cell count
60
 
61
+ Then `marimo check` CLI validates against 5 breaking rules (MB001-MB005). Per-violation penalties:
62
 
63
+ | Rule | Penalty | What it catches |
64
+ |---|---|---|
65
+ | MB001 | -0.30 | Unparsable cells |
66
+ | MB002 | -0.35 | Duplicate variable definitions across cells |
67
+ | MB003 | -0.40 | Cycle dependencies between cells |
68
+ | MB004 | -0.20 | Invalid setup cell dependencies |
69
+ | MB005 | -0.25 | Syntax errors within cells |
70
 
71
+ Clean code (no violations) gets +0.1 bonus.
72
 
73
+ **Skip penalty**: Generating without any exploration incurs -0.1 penalty.
74
 
75
+ ### Repair scoring
76
 
77
+ If generation fails lint/build validation, the observation enters `repair` and exposes structured errors. One repair attempt is allowed:
78
+
79
+ | Condition | Effect |
80
+ |---|---|
81
+ | First generation succeeds | Full eligible generation reward; episode ends |
82
+ | Repair succeeds | Base generation reward * 0.8, plus a small bonus for fixing prior error codes |
83
+ | Repair fails | Base generation reward * 0.3; episode ends |
84
+ | Code repeated unchanged | Additional penalty |
85
 
86
+ ## Search Sources (`sources.py`)
87
+
88
+ All search calls are **async** (httpx + wikipediaapi.AsyncWikipedia). Content is retrieved at section/chunk level and ranked using **BM25** to surface the most relevant parts.
89
 
90
+ | Source | Library | Use Case | Retrieval |
91
+ |---|---|---|---|
92
+ | Wikipedia | `wikipediaapi.AsyncWikipedia` | Fundamentals | Search 3-5 pages -> section tree -> global BM25 ranking |
93
+ | HuggingFace Papers | httpx -> `huggingface.co/api/papers/search` | ML/AI research | Search 3-5 papers -> markdown chunks -> global BM25 ranking |
94
+ | arXiv | httpx -> arXiv Atom API | Math, algorithms, ML, statistics papers | Search 3-5 papers -> abstracts -> global BM25 ranking |
95
+ | Semantic Scholar | httpx -> Graph API | Scholarly metadata and abstracts | Search 3-5 papers -> abstracts -> global BM25 ranking |
96
+ | Docs | httpx + trafilatura | API/library/code details | Fetch allowlisted docs -> chunk -> global BM25 ranking |
97
+ | HF Hub | `huggingface_hub.HfApi` | Model cards, datasets, Spaces | Search Hub entities -> metadata/card snippets |
98
 
99
+ Agents choose tools explicitly. Each tool fetches multiple candidates, chunks them, and returns the top 3-5 chunks globally. Optional small local embeddings can rerank the BM25 shortlist when `EMBEDDINGS_ENABLED=1`.
100
 
101
+ ## Sandbox (`sandbox.py`)
102
 
103
+ Validation follows a fast-to-slow pipeline. Each stage gates the next.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
+ | Check | Tool | Timeout | Purpose |
106
+ |---|---|---|---|
107
+ | `ast_parses` | Python `ast.parse` | ~0ms | Catch syntax errors |
108
+ | `check_marimo` | `marimo check --format json --select MB` | 8s | Catch structural violations |
109
+ | `run_marimo` | `marimo export html` | 7s | Full execution test |
110
+ | `run_manim` | `manim render -ql` | 30s | Full render test |
111
 
112
+ `check_marimo` runs in ~100-200ms and catches MB001-MB005. If it fails, `run_marimo` is skipped (saves ~15s per broken submission).
rewards/__init__.py CHANGED
@@ -2,15 +2,10 @@
2
 
3
  from .exploration import compute_explore_reward
4
  from .generation import compute_generate_reward
5
- from .sandbox import run_marimo, run_manim
6
- from .sources import search, search_hf_papers, search_wikipedia
7
 
8
  __all__ = [
9
  "compute_explore_reward",
10
  "compute_generate_reward",
11
- "run_marimo",
12
- "run_manim",
13
  "search",
14
- "search_hf_papers",
15
- "search_wikipedia",
16
  ]
 
2
 
3
  from .exploration import compute_explore_reward
4
  from .generation import compute_generate_reward
5
+ from .sources import search
 
6
 
7
  __all__ = [
8
  "compute_explore_reward",
9
  "compute_generate_reward",
 
 
10
  "search",
 
 
11
  ]
rewards/exploration.py CHANGED
@@ -1,51 +1,67 @@
1
- """Reward components for the exploration phase.
2
-
3
- During exploration, the agent searches for papers/resources relevant to the
4
- task topic. Rewards measure query quality, result relevance, research breadth,
5
- and exploration efficiency (knowing when to stop).
6
- """
7
 
8
  from __future__ import annotations
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- def query_relevance(query: str, topic: str, keywords_csv: str) -> float:
12
- """Score how relevant the search query is to the task (0-1)."""
 
13
  if not query or not query.strip():
14
  return 0.0
15
 
16
- query_lower = query.strip().lower()
17
  score = 0.0
18
 
19
  if topic.lower() in query_lower:
20
- score += 0.4
21
 
22
  keywords = [k.strip().lower() for k in keywords_csv.split(",") if k.strip()]
23
  if keywords:
24
  hits = sum(1 for kw in keywords if kw in query_lower)
25
- score += 0.4 * (hits / len(keywords))
26
 
27
  if len(query_lower.split()) >= 3:
28
- score += 0.2
29
 
30
- return min(1.0, score)
 
31
 
 
32
 
33
- def result_novelty(
34
- new_content: str, accumulated_context: list[str]
35
- ) -> float:
36
- """Score how much new information this result adds (0-1).
37
 
38
- Penalises repeated searches that return content already seen.
39
- """
40
  if not new_content or not new_content.strip():
41
  return 0.0
42
- if not accumulated_context:
43
  return 1.0
44
 
45
- new_words = set(new_content.lower().split())
46
  seen_words: set[str] = set()
47
- for ctx in accumulated_context:
48
- seen_words.update(ctx.lower().split())
49
 
50
  if not new_words:
51
  return 0.0
@@ -57,82 +73,169 @@ def result_novelty(
57
  def research_breadth(accumulated_context: list[str], min_sources: int = 2) -> float:
58
  """Score whether the agent gathered enough sources (0-1)."""
59
  n = len(accumulated_context)
60
- if n >= min_sources:
61
- return 1.0
62
- return n / min_sources
63
 
64
 
65
  def content_sufficiency(
66
- task_content: str,
67
- keywords_csv: str,
68
- accumulated_context: list[str],
69
  ) -> float:
70
- """Measure how much of the task's keywords are already covered (0-1).
71
-
72
- Combines the task's own content with accumulated research. When this is
73
- high (>0.8), further exploration has diminishing value — the agent already
74
- has enough information.
75
- """
76
  keywords = [k.strip().lower() for k in keywords_csv.split(",") if k.strip()]
77
  if not keywords:
78
- return 1.0 # no keywords to cover
79
 
80
- # Build combined text from task content + all research so far
81
- combined = task_content.lower()
82
  for ctx in accumulated_context:
83
- combined += " " + ctx.lower()
84
 
85
- hits = sum(1 for kw in keywords if kw in combined)
86
- return hits / len(keywords)
87
 
88
 
89
- # -- Weights --
90
- W_QUERY = 0.40
91
- W_NOVELTY = 0.30
92
- W_BREADTH = 0.10
93
- W_SUFFICIENCY_GATE = 0.20 # gates reward by remaining information need
 
94
 
95
- # Flat cost per explore step — agent must expect enough gain to justify it
96
- STEP_COST = 0.05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
 
99
  def compute_explore_reward(
100
  query: str,
101
- result_text: str,
 
 
102
  topic: str,
103
  keywords_csv: str,
104
  task_content: str,
 
 
105
  accumulated_context: list[str],
 
106
  ) -> tuple[float, dict]:
107
- """Compute per-step exploration reward. Returns (total, components).
108
-
109
- Reward is gated by (1 - sufficiency): once the agent has enough info,
110
- further exploration is nearly unrewarded. A flat step cost penalises
111
- unnecessary searches.
112
- """
113
- q_rel = query_relevance(query, topic, keywords_csv)
114
- novelty = result_novelty(result_text, accumulated_context)
115
- breadth = research_breadth(accumulated_context)
 
116
  sufficiency = content_sufficiency(task_content, keywords_csv, accumulated_context)
117
 
118
- # Information need: how much value exploration still has
119
  info_need = max(0.0, 1.0 - sufficiency)
120
-
121
- # Raw reward from query + novelty + breadth
122
- raw = W_QUERY * q_rel + W_NOVELTY * novelty + W_BREADTH * breadth
123
-
124
- # Gate by info need: high sufficiency → low reward for exploring more
125
- # Also add direct sufficiency-gate component so agent sees the signal
126
- total = raw * (0.3 + 0.7 * info_need) + W_SUFFICIENCY_GATE * info_need - STEP_COST
 
 
 
127
  total = max(0.0, total)
128
 
129
  components = {
 
130
  "query_relevance": round(q_rel, 3),
 
 
131
  "result_novelty": round(novelty, 3),
132
- "research_breadth": round(breadth, 3),
 
133
  "content_sufficiency": round(sufficiency, 3),
134
  "info_need": round(info_need, 3),
135
  "step_cost": STEP_COST,
136
  "explore_total": round(total, 4),
137
  }
138
  return total, components
 
 
 
 
 
1
+ """Reward components for the exploration phase."""
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ try:
6
+ from ..research.retrieval import tokenize
7
+ from ..research.types import ResearchResult
8
+ except ImportError: # pragma: no cover - supports direct test execution
9
+ from research.retrieval import tokenize
10
+ from research.types import ResearchResult
11
+
12
+ # Weights. Keep the reward explainable: each component maps to a visible skill.
13
+ W_TOOL_CHOICE = 0.15
14
+ W_QUERY = 0.20
15
+ W_SOURCE_QUALITY = 0.20
16
+ W_COVERAGE_DELTA = 0.20
17
+ W_NOVELTY = 0.15
18
+ W_DIVERSITY = 0.10
19
+
20
+ # Flat per-step penalty — the agent must expect enough gain to justify each search
21
+ STEP_COST = 0.05
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Individual scorers
26
+ # ---------------------------------------------------------------------------
27
 
28
+
29
+ def query_relevance(query: str, topic: str, keywords_csv: str, intent: str = "") -> float:
30
+ """Score how relevant and specific the search query is to the task (0-1)."""
31
  if not query or not query.strip():
32
  return 0.0
33
 
34
+ query_lower = f"{query} {intent}".strip().lower()
35
  score = 0.0
36
 
37
  if topic.lower() in query_lower:
38
+ score += 0.35
39
 
40
  keywords = [k.strip().lower() for k in keywords_csv.split(",") if k.strip()]
41
  if keywords:
42
  hits = sum(1 for kw in keywords if kw in query_lower)
43
+ score += 0.35 * (hits / len(keywords))
44
 
45
  if len(query_lower.split()) >= 3:
46
+ score += 0.15
47
 
48
+ if any(term in query_lower for term in ("equation", "intuition", "visual", "example", "code")):
49
+ score += 0.15
50
 
51
+ return min(1.0, score)
52
 
 
 
 
 
53
 
54
+ def result_novelty(new_content: str, previous_context: list[str]) -> float:
55
+ """Score how much new information this result adds (0-1)."""
56
  if not new_content or not new_content.strip():
57
  return 0.0
58
+ if not previous_context:
59
  return 1.0
60
 
61
+ new_words = set(tokenize(new_content))
62
  seen_words: set[str] = set()
63
+ for ctx in previous_context:
64
+ seen_words.update(tokenize(ctx))
65
 
66
  if not new_words:
67
  return 0.0
 
73
  def research_breadth(accumulated_context: list[str], min_sources: int = 2) -> float:
74
  """Score whether the agent gathered enough sources (0-1)."""
75
  n = len(accumulated_context)
76
+ return 1.0 if n >= min_sources else n / min_sources
 
 
77
 
78
 
79
  def content_sufficiency(
80
+ task_content: str, keywords_csv: str, accumulated_context: list[str],
 
 
81
  ) -> float:
82
+ """Fraction of task keywords covered in task content + research (0-1)."""
 
 
 
 
 
83
  keywords = [k.strip().lower() for k in keywords_csv.split(",") if k.strip()]
84
  if not keywords:
85
+ return 1.0
86
 
87
+ combined = _normalized_text(task_content)
 
88
  for ctx in accumulated_context:
89
+ combined += " " + _normalized_text(ctx)
90
 
91
+ return sum(1 for kw in keywords if _normalized_text(kw) in combined) / len(keywords)
 
92
 
93
 
94
+ def tool_choice_score(tool: str, difficulty: str, query: str, intent: str = "") -> float:
95
+ """Reward selecting a tool that fits the task/source need."""
96
+ text = f"{query} {intent}".lower()
97
+ codeish = any(term in text for term in ("api", "code", "library", "plot", "chart", "lint", "marimo", "manim"))
98
+ paperish = any(term in text for term in ("paper", "research", "recent", "citation", "state of the art"))
99
+ hubish = any(term in text for term in ("model", "dataset", "space", "hugging face", "hf hub"))
100
 
101
+ if codeish:
102
+ return 1.0 if tool == "fetch_docs" else 0.65
103
+ if hubish:
104
+ return 1.0 if tool == "search_hf_hub" else 0.65
105
+ if difficulty == "hard" or paperish:
106
+ return 1.0 if tool in {"search_arxiv", "search_hf_papers", "search_scholar"} else 0.55
107
+ if difficulty == "medium":
108
+ return 1.0 if tool in {"search_wikipedia", "search_arxiv", "search_scholar"} else 0.75
109
+ return 1.0 if tool in {"search_wikipedia", "fetch_docs"} else 0.65
110
+
111
+
112
+ def source_quality(result: ResearchResult) -> float:
113
+ """Measure whether retrieved chunks are usable, not whether the source is trusted."""
114
+ if result.error or not result.chunks:
115
+ return 0.0
116
+
117
+ scores = []
118
+ for chunk in result.chunks:
119
+ metadata = 0.0
120
+ if chunk.title:
121
+ metadata += 0.35
122
+ if chunk.url:
123
+ metadata += 0.35
124
+ if chunk.metadata:
125
+ metadata += 0.30
126
+
127
+ word_count = len(tokenize(chunk.text))
128
+ content = min(1.0, word_count / 80)
129
+ relevance = 1.0 if chunk.score > 0 else 0.5
130
+ scores.append(0.35 * metadata + 0.45 * content + 0.20 * relevance)
131
+
132
+ return min(1.0, sum(scores) / max(len(scores), 1))
133
+
134
+
135
+ def coverage_delta(
136
+ keywords_csv: str,
137
+ task_content: str,
138
+ previous_context: list[str],
139
+ new_content: str,
140
+ ) -> float:
141
+ """Score newly covered keywords/concepts added by this result."""
142
+ keywords = [k.strip().lower() for k in keywords_csv.split(",") if k.strip()]
143
+ if not keywords:
144
+ return 1.0
145
+
146
+ before = _normalized_text(task_content + " " + " ".join(previous_context))
147
+ after = before + " " + _normalized_text(new_content)
148
+ missing_before = [kw for kw in keywords if _normalized_text(kw) not in before]
149
+ if not missing_before:
150
+ return 0.0
151
+ newly_covered = sum(1 for kw in missing_before if _normalized_text(kw) in after)
152
+ return newly_covered / len(missing_before)
153
+
154
+
155
+ def diversity_score(tool: str, used_tools: set[str], result: ResearchResult) -> float:
156
+ """Reward useful source diversity without rewarding empty calls."""
157
+ if result.error or not result.chunks:
158
+ return 0.0
159
+ if tool not in used_tools:
160
+ return 1.0
161
+ unique_urls = {chunk.url for chunk in result.chunks if chunk.url}
162
+ return 0.5 if len(unique_urls) > 1 else 0.25
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Gating
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ def _exploration_gate(sufficiency: float) -> float:
171
+ """Multiplier based on information need.
172
+
173
+ High sufficiency → low multiplier (exploration has little value).
174
+ Low sufficiency → high multiplier (exploration has high value).
175
+ Range: [0.3, 1.0].
176
+ """
177
+ info_need = max(0.0, 1.0 - sufficiency)
178
+ return 0.3 + 0.7 * info_need
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Main reward function
183
+ # ---------------------------------------------------------------------------
184
 
185
 
186
  def compute_explore_reward(
187
  query: str,
188
+ tool: str,
189
+ intent: str,
190
+ result: ResearchResult,
191
  topic: str,
192
  keywords_csv: str,
193
  task_content: str,
194
+ difficulty: str,
195
+ previous_context: list[str],
196
  accumulated_context: list[str],
197
+ used_tools: set[str] | None = None,
198
  ) -> tuple[float, dict]:
199
+ """Compute per-step exploration reward. Returns (total, components)."""
200
+ used_tools = used_tools or set()
201
+ result_text = result.text
202
+
203
+ t_choice = tool_choice_score(tool, difficulty, query, intent)
204
+ q_rel = query_relevance(query, topic, keywords_csv, intent)
205
+ src_quality = source_quality(result)
206
+ delta = coverage_delta(keywords_csv, task_content, previous_context, result_text)
207
+ novelty = result_novelty(result_text, previous_context)
208
+ diversity = diversity_score(tool, used_tools, result)
209
  sufficiency = content_sufficiency(task_content, keywords_csv, accumulated_context)
210
 
 
211
  info_need = max(0.0, 1.0 - sufficiency)
212
+ raw = (
213
+ W_TOOL_CHOICE * t_choice
214
+ + W_QUERY * q_rel
215
+ + W_SOURCE_QUALITY * src_quality
216
+ + W_COVERAGE_DELTA * delta
217
+ + W_NOVELTY * novelty
218
+ + W_DIVERSITY * diversity
219
+ )
220
+ gate = _exploration_gate(sufficiency)
221
+ total = raw * gate + 0.10 * info_need - STEP_COST
222
  total = max(0.0, total)
223
 
224
  components = {
225
+ "tool_choice": round(t_choice, 3),
226
  "query_relevance": round(q_rel, 3),
227
+ "source_quality": round(src_quality, 3),
228
+ "coverage_delta": round(delta, 3),
229
  "result_novelty": round(novelty, 3),
230
+ "diversity": round(diversity, 3),
231
+ "research_breadth": round(research_breadth(accumulated_context), 3),
232
  "content_sufficiency": round(sufficiency, 3),
233
  "info_need": round(info_need, 3),
234
  "step_cost": STEP_COST,
235
  "explore_total": round(total, 4),
236
  }
237
  return total, components
238
+
239
+
240
+ def _normalized_text(text: str) -> str:
241
+ return " ".join(tokenize(text))
rewards/generation.py CHANGED
@@ -2,19 +2,56 @@
2
 
3
  After exploration, the agent generates marimo/manim code. Rewards measure
4
  code quality, execution success, keyword coverage, format match, structural
5
- quality, and narration (manim only).
 
 
 
 
 
 
 
 
 
6
  """
7
 
8
  from __future__ import annotations
9
 
 
 
10
  from typing import TYPE_CHECKING
11
 
12
- from .sandbox import ast_parses
13
 
14
  if TYPE_CHECKING:
15
  from ..task_bank import Task
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  # ---------------------------------------------------------------------------
19
  # Individual scorers
20
  # ---------------------------------------------------------------------------
@@ -28,8 +65,7 @@ def keyword_coverage(code: str, keywords_csv: str) -> float:
28
  if not keywords:
29
  return 0.0
30
  code_lower = code.lower()
31
- hits = sum(1 for kw in keywords if kw in code_lower)
32
- return hits / len(keywords)
33
 
34
 
35
  def format_match(chosen_format: str, task: Task) -> float:
@@ -43,8 +79,14 @@ def format_match(chosen_format: str, task: Task) -> float:
43
 
44
 
45
  def marimo_structure(code: str, task: Task) -> float:
46
- """Score structural quality of a marimo notebook (0-1)."""
 
 
 
 
47
  score = 0.0
 
 
48
  if "import marimo" in code or "from marimo" in code:
49
  score += 0.2
50
  if "marimo.App" in code or "mo.App" in code:
@@ -54,22 +96,28 @@ def marimo_structure(code: str, task: Task) -> float:
54
  score += 0.2
55
  elif cell_count >= 1:
56
  score += 0.1
 
57
  ui_patterns = ["mo.ui.", "mo.md(", "mo.Html", "mo.accordion", "mo.callout"]
58
- ui_hits = sum(1 for p in ui_patterns if p in code)
59
- score += min(0.2, ui_hits * 0.05)
60
  viz_patterns = ["plt.", "px.", "altair", "matplotlib", "plotly", "mo.ui.slider"]
61
- viz_hits = sum(1 for p in viz_patterns if p in code)
62
- if task.data_available and viz_hits > 0:
63
- score += 0.2
64
- elif viz_hits > 0:
65
- score += 0.1
66
- if task.tier == "advanced" and cell_count >= 6:
67
- score += 0.1
68
- elif task.tier == "intermediate" and cell_count >= 4:
69
  score += 0.1
70
- elif task.tier == "beginner" and cell_count >= 2:
 
 
 
71
  score += 0.1
72
- return min(1.0, score)
 
 
 
 
 
73
 
74
 
75
  def manim_structure(code: str, task: Task) -> float:
@@ -83,37 +131,24 @@ def manim_structure(code: str, task: Task) -> float:
83
  score += 0.2
84
  if "def construct" in code:
85
  score += 0.1
 
86
  anim_patterns = [
87
- "self.play(",
88
- "self.wait(",
89
- "Create(",
90
- "FadeIn(",
91
- "FadeOut(",
92
- "Transform(",
93
- "Write(",
94
- "MoveToTarget",
95
- "Indicate(",
96
  "ReplacementTransform(",
97
  ]
98
  anim_hits = sum(1 for p in anim_patterns if p in code)
99
  score += min(0.3, anim_hits * 0.05)
 
100
  math_patterns = ["MathTex(", "Tex(", "Axes(", "NumberPlane(", "Graph("]
101
- math_hits = sum(1 for p in math_patterns if p in code)
102
- if math_hits > 0:
103
- score += 0.1
104
- if task.tier == "advanced" and anim_hits >= 6:
105
- score += 0.1
106
- elif task.tier == "intermediate" and anim_hits >= 4:
107
- score += 0.1
108
- elif task.tier == "beginner" and anim_hits >= 2:
109
  score += 0.1
110
- return min(1.0, score)
111
 
 
 
 
112
 
113
- def structure_score(code: str, fmt: str, task: Task) -> float:
114
- if fmt == "marimo":
115
- return marimo_structure(code, task)
116
- return manim_structure(code, task)
117
 
118
 
119
  def narration_score(narration: str, fmt: str) -> float:
@@ -122,15 +157,14 @@ def narration_score(narration: str, fmt: str) -> float:
122
  return 1.0
123
  if not narration or not narration.strip():
124
  return 0.0
125
- score = 0.0
126
  words = narration.split()
 
127
  if len(words) >= 30:
128
  score += 0.4
129
  elif len(words) >= 10:
130
  score += 0.2
131
  scene_markers = ["scene", "step", "first", "next", "then", "finally", "now"]
132
- marker_hits = sum(1 for m in scene_markers if m in narration.lower())
133
- score += min(0.3, marker_hits * 0.1)
134
  if len(words) >= 50:
135
  score += 0.3
136
  elif len(words) >= 20:
@@ -139,35 +173,25 @@ def narration_score(narration: str, fmt: str) -> float:
139
 
140
 
141
  def context_usage(code: str, accumulated_context: list[str]) -> float:
142
- """Score whether the generated code incorporates research findings (0-1).
143
-
144
- Higher score if the code references terms found during exploration.
145
- """
146
  if not accumulated_context:
147
- return 0.5 # no exploration context to compare against
148
 
149
  context_words: set[str] = set()
150
  for ctx in accumulated_context:
151
- context_words.update(
152
- w.lower() for w in ctx.split() if len(w) > 3
153
- )
154
 
155
  if not context_words:
156
  return 0.5
157
 
158
- code_words = set(w.lower() for w in code.split() if len(w) > 3)
159
  overlap = code_words & context_words
160
  return min(1.0, len(overlap) / max(len(context_words), 1) * 5)
161
 
162
 
163
- # -- Weights for generation reward --
164
- W_CODE_VALID = 0.15
165
- W_CODE_RUNS = 0.15
166
- W_COVERAGE = 0.15
167
- W_FORMAT = 0.10
168
- W_STRUCTURE = 0.15
169
- W_NARRATION = 0.10
170
- W_CONTEXT_USE = 0.20 # rewards using exploration findings
171
 
172
 
173
  def compute_generate_reward(
@@ -178,33 +202,36 @@ def compute_generate_reward(
178
  exec_success: bool,
179
  accumulated_context: list[str],
180
  ) -> tuple[float, dict]:
181
- """Compute the generation-phase reward. Returns (total, components)."""
 
 
 
 
182
  c_valid = 1.0 if ast_parses(code) else 0.0
183
  c_runs = 1.0 if exec_success else 0.0
184
  c_coverage = keyword_coverage(code, task.keywords)
185
  c_format = format_match(fmt, task)
186
- c_struct = structure_score(code, fmt, task)
187
  c_narr = narration_score(narration, fmt)
188
  c_ctx = context_usage(code, accumulated_context)
189
 
190
- # Redistribute narration weight to structure for marimo
191
- if fmt == "marimo":
192
- w_struct = W_STRUCTURE + W_NARRATION
193
- w_narr = 0.0
194
- else:
195
- w_struct = W_STRUCTURE
196
- w_narr = W_NARRATION
197
-
198
- total = (
199
- W_CODE_VALID * c_valid
200
- + W_CODE_RUNS * c_runs
201
- + W_COVERAGE * c_coverage
202
- + W_FORMAT * c_format
203
- + w_struct * c_struct
204
- + w_narr * c_narr
205
- + W_CONTEXT_USE * c_ctx
206
  )
207
 
 
 
 
 
 
 
 
 
208
  components = {
209
  "code_valid": round(c_valid, 3),
210
  "code_runs": round(c_runs, 3),
@@ -216,3 +243,44 @@ def compute_generate_reward(
216
  "generate_total": round(total, 4),
217
  }
218
  return total, components
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  After exploration, the agent generates marimo/manim code. Rewards measure
4
  code quality, execution success, keyword coverage, format match, structural
5
+ quality, narration (manim only), and context usage.
6
+
7
+ Scoring model:
8
+ quality = weighted sum of (coverage, format, structure, narration, context)
9
+ total = quality × gate
10
+
11
+ Gates (multiplicative):
12
+ - code doesn't parse → total = 0
13
+ - code doesn't run → total = quality × 0.4
14
+ - code runs → total = quality × 1.0
15
  """
16
 
17
  from __future__ import annotations
18
 
19
+ import hashlib
20
+ import re
21
  from typing import TYPE_CHECKING
22
 
23
+ from .sandbox import ast_parses, check_marimo
24
 
25
  if TYPE_CHECKING:
26
  from ..task_bank import Task
27
 
28
 
29
+ # ---------------------------------------------------------------------------
30
+ # Component weights
31
+ # ---------------------------------------------------------------------------
32
+
33
+ _WEIGHTS = {
34
+ "coverage": 0.20,
35
+ "format": 0.10,
36
+ "structure": 0.20,
37
+ "narration": 0.15,
38
+ "context": 0.35,
39
+ }
40
+
41
+ GATE_RUNS_FAIL = 0.4 # quality multiplier when code doesn't execute
42
+
43
+
44
+ def _get_weights(fmt: str) -> dict[str, float]:
45
+ """Return component weights for the given format.
46
+
47
+ Marimo has no narration — its weight is redistributed to structure.
48
+ """
49
+ w = dict(_WEIGHTS)
50
+ if fmt == "marimo":
51
+ w["structure"] += w.pop("narration")
52
+ return w
53
+
54
+
55
  # ---------------------------------------------------------------------------
56
  # Individual scorers
57
  # ---------------------------------------------------------------------------
 
65
  if not keywords:
66
  return 0.0
67
  code_lower = code.lower()
68
+ return sum(1 for kw in keywords if kw in code_lower) / len(keywords)
 
69
 
70
 
71
  def format_match(chosen_format: str, task: Task) -> float:
 
79
 
80
 
81
  def marimo_structure(code: str, task: Task) -> float:
82
+ """Score structural quality of a marimo notebook (0-1).
83
+
84
+ Additive scoring for good patterns, penalties from ``marimo check``
85
+ for breaking violations (duplicate defs, cycles, etc.).
86
+ """
87
  score = 0.0
88
+
89
+ # Positive signals
90
  if "import marimo" in code or "from marimo" in code:
91
  score += 0.2
92
  if "marimo.App" in code or "mo.App" in code:
 
96
  score += 0.2
97
  elif cell_count >= 1:
98
  score += 0.1
99
+
100
  ui_patterns = ["mo.ui.", "mo.md(", "mo.Html", "mo.accordion", "mo.callout"]
101
+ score += min(0.2, sum(0.05 for p in ui_patterns if p in code))
102
+
103
  viz_patterns = ["plt.", "px.", "altair", "matplotlib", "plotly", "mo.ui.slider"]
104
+ if any(p in code for p in viz_patterns):
105
+ score += 0.2 if task.data_available else 0.1
106
+
107
+ tier_thresholds = {"advanced": 6, "intermediate": 4, "beginner": 2}
108
+ if cell_count >= tier_thresholds.get(task.tier, 2):
 
 
 
109
  score += 0.1
110
+
111
+ # Marimo check: penalize breaking violations, bonus for clean code
112
+ passed, _, violations = check_marimo(code)
113
+ if passed:
114
  score += 0.1
115
+ else:
116
+ penalty = {"MB002": 0.35, "MB003": 0.4, "MB005": 0.25, "MB001": 0.3, "MB004": 0.2}
117
+ for v in violations:
118
+ score -= penalty.get(v, 0.15)
119
+
120
+ return max(0.0, min(1.0, score))
121
 
122
 
123
  def manim_structure(code: str, task: Task) -> float:
 
131
  score += 0.2
132
  if "def construct" in code:
133
  score += 0.1
134
+
135
  anim_patterns = [
136
+ "self.play(", "self.wait(", "Create(", "FadeIn(", "FadeOut(",
137
+ "Transform(", "Write(", "MoveToTarget", "Indicate(",
 
 
 
 
 
 
 
138
  "ReplacementTransform(",
139
  ]
140
  anim_hits = sum(1 for p in anim_patterns if p in code)
141
  score += min(0.3, anim_hits * 0.05)
142
+
143
  math_patterns = ["MathTex(", "Tex(", "Axes(", "NumberPlane(", "Graph("]
144
+ if any(p in code for p in math_patterns):
 
 
 
 
 
 
 
145
  score += 0.1
 
146
 
147
+ tier_thresholds = {"advanced": 6, "intermediate": 4, "beginner": 2}
148
+ if anim_hits >= tier_thresholds.get(task.tier, 2):
149
+ score += 0.1
150
 
151
+ return min(1.0, score)
 
 
 
152
 
153
 
154
  def narration_score(narration: str, fmt: str) -> float:
 
157
  return 1.0
158
  if not narration or not narration.strip():
159
  return 0.0
 
160
  words = narration.split()
161
+ score = 0.0
162
  if len(words) >= 30:
163
  score += 0.4
164
  elif len(words) >= 10:
165
  score += 0.2
166
  scene_markers = ["scene", "step", "first", "next", "then", "finally", "now"]
167
+ score += min(0.3, sum(0.1 for m in scene_markers if m in narration.lower()))
 
168
  if len(words) >= 50:
169
  score += 0.3
170
  elif len(words) >= 20:
 
173
 
174
 
175
  def context_usage(code: str, accumulated_context: list[str]) -> float:
176
+ """Score whether the generated code incorporates research findings (0-1)."""
 
 
 
177
  if not accumulated_context:
178
+ return 0.5
179
 
180
  context_words: set[str] = set()
181
  for ctx in accumulated_context:
182
+ context_words.update(_tokens(ctx))
 
 
183
 
184
  if not context_words:
185
  return 0.5
186
 
187
+ code_words = set(_tokens(code))
188
  overlap = code_words & context_words
189
  return min(1.0, len(overlap) / max(len(context_words), 1) * 5)
190
 
191
 
192
+ # ---------------------------------------------------------------------------
193
+ # Main reward function
194
+ # ---------------------------------------------------------------------------
 
 
 
 
 
195
 
196
 
197
  def compute_generate_reward(
 
202
  exec_success: bool,
203
  accumulated_context: list[str],
204
  ) -> tuple[float, dict]:
205
+ """Compute the generation-phase reward. Returns (total, components).
206
+
207
+ ``code_valid`` and ``code_runs`` act as gates: broken code gets the
208
+ quality score heavily discounted rather than losing a single 0.15 component.
209
+ """
210
  c_valid = 1.0 if ast_parses(code) else 0.0
211
  c_runs = 1.0 if exec_success else 0.0
212
  c_coverage = keyword_coverage(code, task.keywords)
213
  c_format = format_match(fmt, task)
214
+ c_struct = (marimo_structure if fmt == "marimo" else manim_structure)(code, task)
215
  c_narr = narration_score(narration, fmt)
216
  c_ctx = context_usage(code, accumulated_context)
217
 
218
+ w = _get_weights(fmt)
219
+ quality = (
220
+ w["coverage"] * c_coverage
221
+ + w["format"] * c_format
222
+ + w["structure"] * c_struct
223
+ + w.get("narration", 0.0) * c_narr
224
+ + w["context"] * c_ctx
 
 
 
 
 
 
 
 
 
225
  )
226
 
227
+ # Apply gates
228
+ if c_valid == 0.0:
229
+ total = 0.0
230
+ elif c_runs == 0.0:
231
+ total = quality * GATE_RUNS_FAIL
232
+ else:
233
+ total = quality
234
+
235
  components = {
236
  "code_valid": round(c_valid, 3),
237
  "code_runs": round(c_runs, 3),
 
243
  "generate_total": round(total, 4),
244
  }
245
  return total, components
246
+
247
+
248
+ def adjust_repair_reward(
249
+ base_reward: float,
250
+ *,
251
+ repair_success: bool,
252
+ previous_error_codes: list[str],
253
+ new_error_codes: list[str],
254
+ previous_code: str,
255
+ repaired_code: str,
256
+ ) -> tuple[float, dict]:
257
+ """Discount repaired code but reward fixing the specific prior failure."""
258
+ repeated = _fingerprint(previous_code) == _fingerprint(repaired_code)
259
+ fixed_prior = bool(previous_error_codes) and not (
260
+ set(previous_error_codes) & set(new_error_codes)
261
+ )
262
+
263
+ if repair_success:
264
+ reward = base_reward * 0.8 + (0.1 if fixed_prior else 0.0)
265
+ else:
266
+ reward = base_reward * 0.3
267
+
268
+ if repeated:
269
+ reward -= 0.15
270
+
271
+ reward = max(0.0, min(1.0, reward))
272
+ return reward, {
273
+ "repair_success": 1.0 if repair_success else 0.0,
274
+ "fixed_prior_errors": 1.0 if fixed_prior else 0.0,
275
+ "repeated_code": 1.0 if repeated else 0.0,
276
+ "repair_total": round(reward, 4),
277
+ }
278
+
279
+
280
+ def _tokens(text: str) -> list[str]:
281
+ return [w for w in re.findall(r"\w+", text.lower()) if len(w) > 3]
282
+
283
+
284
+ def _fingerprint(code: str) -> str:
285
+ normalized = re.sub(r"\s+", "", code)
286
+ return hashlib.sha256(normalized.encode()).hexdigest()
rewards/sandbox.py CHANGED
@@ -1,11 +1,45 @@
1
  """Sandbox execution for marimo and manim code."""
2
 
3
  import ast
 
4
  import subprocess
5
  import tempfile
 
 
6
  from pathlib import Path
7
 
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  def ast_parses(code: str) -> bool:
10
  """Check whether the code is valid Python (AST-parseable)."""
11
  try:
@@ -34,19 +68,78 @@ def extract_scene_class(code: str) -> str | None:
34
  return None
35
 
36
 
37
- def run_marimo(code: str, timeout: int = 15) -> tuple[bool, str]:
38
- """Try exporting a marimo notebook to HTML. Returns (success, message)."""
 
 
 
 
 
 
 
 
 
 
39
  with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
40
  f.write(code)
41
  f.flush()
42
  tmp = f.name
43
  try:
44
  result = subprocess.run(
45
- ["marimo", "export", "html", tmp],
46
  capture_output=True,
47
  text=True,
48
  timeout=timeout,
49
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  if result.returncode == 0:
51
  return True, "marimo export succeeded"
52
  return False, result.stderr[:500]
@@ -81,3 +174,68 @@ def run_manim(code: str, timeout: int = 30) -> tuple[bool, str]:
81
  return False, "manim not installed"
82
  except subprocess.TimeoutExpired:
83
  return False, "manim render timed out"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Sandbox execution for marimo and manim code."""
2
 
3
  import ast
4
+ import json
5
  import subprocess
6
  import tempfile
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
  from pathlib import Path
10
 
11
 
12
+ @dataclass
13
+ class SandboxResult:
14
+ """Structured lint/build result returned to the environment."""
15
+
16
+ fmt: str
17
+ parses: bool
18
+ check_passed: bool
19
+ exec_success: bool
20
+ message: str
21
+ errors: list[dict[str, Any]] = field(default_factory=list)
22
+
23
+ @property
24
+ def error_codes(self) -> list[str]:
25
+ codes: list[str] = []
26
+ for error in self.errors:
27
+ code = error.get("code")
28
+ if code and code not in codes:
29
+ codes.append(str(code))
30
+ return codes
31
+
32
+ def render_errors(self) -> str:
33
+ if not self.errors:
34
+ return self.message
35
+ lines = []
36
+ for error in self.errors:
37
+ code = error.get("code", "error")
38
+ message = error.get("message", "")
39
+ lines.append(f"{code}: {message}".strip())
40
+ return "\n".join(lines)
41
+
42
+
43
  def ast_parses(code: str) -> bool:
44
  """Check whether the code is valid Python (AST-parseable)."""
45
  try:
 
68
  return None
69
 
70
 
71
+ # ---------------------------------------------------------------------------
72
+ # Marimo validation via `marimo check` CLI
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def check_marimo(code: str, timeout: int = 8) -> tuple[bool, str, list[str]]:
77
+ """Run ``marimo check`` on code. Returns (passed, message, rule_codes).
78
+
79
+ Catches breaking rules MB001-MB005: unparsable cells, duplicate
80
+ definitions, cycle dependencies, setup cell issues, syntax errors.
81
+ Runs in ~100-200ms — much faster than full ``marimo export html``.
82
+ """
83
  with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
84
  f.write(code)
85
  f.flush()
86
  tmp = f.name
87
  try:
88
  result = subprocess.run(
89
+ ["marimo", "check", "--format", "json", "--select", "MB", tmp],
90
  capture_output=True,
91
  text=True,
92
  timeout=timeout,
93
  )
94
+ data = json.loads(result.stdout)
95
+ issues = data.get("issues", [])
96
+ if not issues:
97
+ return True, "marimo check passed", []
98
+
99
+ codes = list({i["code"] for i in issues})
100
+ first_msg = issues[0].get("message", "unknown error")
101
+ fix_hint = issues[0].get("fix", "")
102
+ msg = f"{first_msg}\n{fix_hint}".strip() if fix_hint else first_msg
103
+ return False, msg, codes
104
+
105
+ except FileNotFoundError:
106
+ return False, "marimo not installed", ["MARIMO_MISSING"]
107
+ except subprocess.TimeoutExpired:
108
+ return False, "marimo check timed out", ["MARIMO_TIMEOUT"]
109
+ except (json.JSONDecodeError, KeyError):
110
+ return False, "marimo check output unparseable", ["MARIMO_CHECK_PARSE"]
111
+ finally:
112
+ Path(tmp).unlink(missing_ok=True)
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Full execution
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def run_marimo(code: str, timeout: int = 15, *, skip_check: bool = False) -> tuple[bool, str]:
121
+ """Export a marimo notebook to HTML, optionally running static checks first.
122
+
123
+ Runs ``marimo check`` first (fast static analysis). If that fails,
124
+ returns immediately without the expensive export step.
125
+ """
126
+ check_timeout = min(8, max(1, timeout // 2))
127
+ if not skip_check:
128
+ passed, msg, _violations = check_marimo(code, timeout=check_timeout)
129
+ if not passed:
130
+ return False, msg
131
+
132
+ with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
133
+ f.write(code)
134
+ f.flush()
135
+ tmp = f.name
136
+ try:
137
+ result = subprocess.run(
138
+ ["marimo", "export", "html", tmp],
139
+ capture_output=True,
140
+ text=True,
141
+ timeout=max(1, timeout - check_timeout),
142
+ )
143
  if result.returncode == 0:
144
  return True, "marimo export succeeded"
145
  return False, result.stderr[:500]
 
174
  return False, "manim not installed"
175
  except subprocess.TimeoutExpired:
176
  return False, "manim render timed out"
177
+
178
+
179
+ def validate_code(fmt: str, code: str) -> SandboxResult:
180
+ """Validate code and return parseable feedback for generation/repair."""
181
+ if not ast_parses(code):
182
+ return SandboxResult(
183
+ fmt=fmt,
184
+ parses=False,
185
+ check_passed=False,
186
+ exec_success=False,
187
+ message="Code has syntax errors and cannot be parsed.",
188
+ errors=[{"code": "PY_SYNTAX", "message": "Code has syntax errors."}],
189
+ )
190
+
191
+ if fmt == "marimo":
192
+ check_passed, check_msg, codes = check_marimo(code)
193
+ if not check_passed:
194
+ return SandboxResult(
195
+ fmt=fmt,
196
+ parses=True,
197
+ check_passed=False,
198
+ exec_success=False,
199
+ message=check_msg,
200
+ errors=[{"code": code, "message": check_msg} for code in codes],
201
+ )
202
+
203
+ exec_success, exec_msg = run_marimo(code, skip_check=True)
204
+ return SandboxResult(
205
+ fmt=fmt,
206
+ parses=True,
207
+ check_passed=True,
208
+ exec_success=exec_success,
209
+ message=exec_msg,
210
+ errors=[] if exec_success else [{"code": "MARIMO_EXPORT", "message": exec_msg}],
211
+ )
212
+
213
+ if fmt == "manim":
214
+ scene = extract_scene_class(code)
215
+ if scene is None:
216
+ return SandboxResult(
217
+ fmt=fmt,
218
+ parses=True,
219
+ check_passed=False,
220
+ exec_success=False,
221
+ message="No Scene subclass found in code",
222
+ errors=[{"code": "MANIM_NO_SCENE", "message": "No Scene subclass found."}],
223
+ )
224
+ exec_success, exec_msg = run_manim(code)
225
+ return SandboxResult(
226
+ fmt=fmt,
227
+ parses=True,
228
+ check_passed=True,
229
+ exec_success=exec_success,
230
+ message=exec_msg,
231
+ errors=[] if exec_success else [{"code": "MANIM_RENDER", "message": exec_msg}],
232
+ )
233
+
234
+ return SandboxResult(
235
+ fmt=fmt,
236
+ parses=True,
237
+ check_passed=False,
238
+ exec_success=False,
239
+ message=f"Unknown format: {fmt}",
240
+ errors=[{"code": "UNKNOWN_FORMAT", "message": f"Unknown format: {fmt}"}],
241
+ )
rewards/sources.py CHANGED
@@ -1,321 +1,34 @@
1
- """Async search sources for the exploration phase.
2
 
3
- Two backends:
4
- - HuggingFace Papers: ML-focused semantic search via huggingface_hub
5
- - Wikipedia: general topics via wikipediaapi (section-level + BM25 RAG)
6
-
7
- The agent's query is routed to the most appropriate source, or the agent
8
- can specify a source prefix (e.g. "wiki: merge sort", "hf: attention").
9
-
10
- All external calls use async I/O (httpx / wikipediaapi.AsyncWikipedia).
11
  """
12
 
13
  from __future__ import annotations
14
 
15
- import math
16
- import re
17
- from collections import Counter
18
-
19
- import httpx
20
- import wikipediaapi
21
-
22
- HF_MAX_RESULTS = 1
23
- WIKI_TOP_SECTIONS = 3
24
-
25
- # BM25 parameters
26
- _BM25_K1 = 1.5
27
- _BM25_B = 0.75
28
-
29
-
30
- # ---------------------------------------------------------------------------
31
- # BM25 scoring (pure Python, no external deps)
32
- # ---------------------------------------------------------------------------
33
-
34
- _STOP_WORDS = {
35
- "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
36
- "have", "has", "had", "do", "does", "did", "will", "would", "could",
37
- "should", "may", "might", "shall", "can", "need", "dare", "ought",
38
- "to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
39
- "into", "through", "during", "before", "after", "and", "but", "or",
40
- "not", "no", "nor", "so", "yet", "both", "either", "neither",
41
- "this", "that", "these", "those", "it", "its", "he", "she", "they",
42
- }
43
-
44
-
45
- def _tokenize(text: str) -> list[str]:
46
- """Lowercase alphanumeric tokenization, stop words removed."""
47
- return [w for w in re.findall(r"\w+", text.lower()) if w not in _STOP_WORDS and len(w) > 1]
48
-
49
-
50
- def _bm25_rank(
51
- query: str, documents: list[tuple[str, str]], top_k: int = 3
52
- ) -> list[tuple[float, str, str]]:
53
- """Rank (title, text) documents against query using BM25.
54
-
55
- Returns top_k results sorted by score descending.
56
- """
57
- if not documents:
58
- return []
59
-
60
- query_terms = _tokenize(query)
61
- if not query_terms:
62
- return [(0.0, t, txt) for t, txt in documents[:top_k]]
63
-
64
- # Precompute document token stats
65
- doc_tokens = [_tokenize(f"{title} {text}") for title, text in documents]
66
- doc_lengths = [len(t) for t in doc_tokens]
67
- avgdl = sum(doc_lengths) / max(len(doc_lengths), 1)
68
- n_docs = len(documents)
69
-
70
- # Document frequency per query term
71
- df: dict[str, int] = {}
72
- for term in set(query_terms):
73
- df[term] = sum(1 for tokens in doc_tokens if term in tokens)
74
-
75
- # Score each document
76
- scored: list[tuple[float, str, str]] = []
77
- for i, (title, text) in enumerate(documents):
78
- tf_counts = Counter(doc_tokens[i])
79
- dl = doc_lengths[i]
80
- score = 0.0
81
- for term in query_terms:
82
- if term not in df or df[term] == 0:
83
- continue
84
- idf = math.log((n_docs - df[term] + 0.5) / (df[term] + 0.5) + 1.0)
85
- tf = tf_counts.get(term, 0)
86
- numerator = tf * (_BM25_K1 + 1)
87
- denominator = tf + _BM25_K1 * (1 - _BM25_B + _BM25_B * dl / max(avgdl, 1))
88
- score += idf * numerator / denominator
89
- scored.append((score, title, text))
90
-
91
- scored.sort(key=lambda x: x[0], reverse=True)
92
- return scored[:top_k]
93
-
94
-
95
- # ---------------------------------------------------------------------------
96
- # Wikipedia section flattening
97
- # ---------------------------------------------------------------------------
98
-
99
- _SKIP_SECTIONS = {
100
- "references", "external links", "see also", "further reading",
101
- "notes", "citations", "bibliography", "sources",
102
- }
103
-
104
-
105
- def _flatten_sections(
106
- sections: list[wikipediaapi.WikipediaPageSection],
107
- max_depth: int = 2,
108
- _depth: int = 0,
109
- ) -> list[tuple[str, str]]:
110
- """Flatten Wikipedia section tree into (title, text) pairs."""
111
- result: list[tuple[str, str]] = []
112
- for section in sections:
113
- if section.title.lower() in _SKIP_SECTIONS:
114
- continue
115
- if section.text.strip():
116
- result.append((section.title, section.text.strip()))
117
- if _depth < max_depth and section.sections:
118
- result.extend(
119
- _flatten_sections(section.sections, max_depth, _depth + 1)
120
- )
121
- return result
122
-
123
-
124
- # ---------------------------------------------------------------------------
125
- # Wikipedia (async, section-level BM25)
126
- # ---------------------------------------------------------------------------
127
-
128
- async def search_wikipedia(
129
- query: str, top_sections: int = WIKI_TOP_SECTIONS
130
- ) -> str:
131
- """Search Wikipedia and return the most relevant sections via BM25.
132
-
133
- Flow: search(query) -> top page -> get sections -> BM25 rank -> top-k.
134
- """
135
- try:
136
- wiki = wikipediaapi.AsyncWikipedia(
137
- user_agent="ExplainerEnv/1.0 (hackathon project)",
138
- language="en",
139
- )
140
-
141
- # Search for the top page
142
- search_results = await wiki.search(query, limit=1)
143
- if not search_results or not search_results.pages:
144
- return f"No Wikipedia results for: {query}"
145
-
146
- # pages is a dict keyed by title
147
- title = next(iter(search_results.pages))
148
- page = wiki.page(title)
149
-
150
- # Check page exists
151
- exists = await page.exists()
152
- if not exists:
153
- return f"No Wikipedia article found for: {query}"
154
-
155
- # Get summary + sections
156
- summary = await page.summary
157
- sections = await page.sections
158
 
159
- # Build document list: summary as first doc, then flattened sections
160
- docs: list[tuple[str, str]] = []
161
- if summary:
162
- docs.append((title, summary))
163
- docs.extend(_flatten_sections(sections))
164
 
165
- if not docs:
166
- return f"Wikipedia article '{title}' has no content."
167
-
168
- # BM25 rank sections against query
169
- ranked = _bm25_rank(query, docs, top_k=top_sections)
170
-
171
- parts = []
172
- for score, sec_title, sec_text in ranked:
173
- # Truncate long sections to keep total size reasonable
174
- trimmed = sec_text[:800] if len(sec_text) > 800 else sec_text
175
- parts.append(f"## {sec_title}\n{trimmed}")
176
-
177
- return f"Wikipedia: {title}\n\n" + "\n\n---\n\n".join(parts)
178
-
179
- except Exception as e:
180
- return f"Wikipedia search error: {e}"
181
-
182
-
183
- # ---------------------------------------------------------------------------
184
- # HuggingFace Papers (async, httpx + read_paper)
185
- # ---------------------------------------------------------------------------
186
-
187
- async def search_hf_papers(
188
- query: str, max_results: int = HF_MAX_RESULTS
189
  ) -> str:
190
- """Search HuggingFace Papers (semantic search) and read top result's content.
191
-
192
- Flow: search(query) -> top paper ID -> read_paper(id) -> BM25 chunk.
193
- """
194
- try:
195
- async with httpx.AsyncClient(timeout=15.0) as client:
196
- # 1. Search for papers
197
- resp = await client.get(
198
- "https://huggingface.co/api/papers/search",
199
- params={"q": query, "limit": max_results},
200
- headers={"User-Agent": "ExplainerEnv/1.0"},
201
- )
202
- resp.raise_for_status()
203
- papers = resp.json()
204
-
205
- if not papers:
206
- return f"No HF papers found for: {query}"
207
-
208
- paper = papers[0]
209
- paper_id = paper.get("id", "")
210
- title = paper.get("title", "Untitled")
211
- summary = paper.get("summary", "")
212
-
213
- if not paper_id:
214
- # No paper ID — return just the search result
215
- return f"Title: {title}\nAbstract: {summary[:600]}"
216
-
217
- # 2. Read paper markdown content
218
- md_resp = await client.get(
219
- f"https://huggingface.co/papers/{paper_id}.md",
220
- headers={"User-Agent": "ExplainerEnv/1.0"},
221
- follow_redirects=True,
222
- )
223
-
224
- if md_resp.status_code == 200 and md_resp.text.strip():
225
- md_content = md_resp.text
226
- # Chunk markdown by headings
227
- chunks = _chunk_markdown(md_content)
228
- if chunks:
229
- ranked = _bm25_rank(query, chunks, top_k=3)
230
- parts = [f"Title: {title}\nPaper ID: {paper_id}\n"]
231
- for _score, sec_title, sec_text in ranked:
232
- trimmed = sec_text[:800] if len(sec_text) > 800 else sec_text
233
- parts.append(f"## {sec_title}\n{trimmed}")
234
- return "\n\n---\n\n".join(parts)
235
-
236
- # Fallback: return abstract only
237
- return (
238
- f"Title: {title}\n"
239
- f"Paper ID: {paper_id}\n"
240
- f"Abstract: {summary[:600]}"
241
- )
242
-
243
- except Exception as e:
244
- return f"HF Papers search error: {e}"
245
-
246
-
247
- def _chunk_markdown(md_text: str) -> list[tuple[str, str]]:
248
- """Split markdown text into (heading, body) chunks."""
249
- chunks: list[tuple[str, str]] = []
250
- current_heading = "Introduction"
251
- current_lines: list[str] = []
252
-
253
- for line in md_text.split("\n"):
254
- if line.startswith("#"):
255
- # Save previous chunk
256
- body = "\n".join(current_lines).strip()
257
- if body:
258
- chunks.append((current_heading, body))
259
- # Start new chunk
260
- current_heading = line.lstrip("#").strip() or "Section"
261
- current_lines = []
262
- else:
263
- current_lines.append(line)
264
-
265
- # Save last chunk
266
- body = "\n".join(current_lines).strip()
267
- if body:
268
- chunks.append((current_heading, body))
269
-
270
- return chunks
271
-
272
-
273
- # ---------------------------------------------------------------------------
274
- # Router
275
- # ---------------------------------------------------------------------------
276
-
277
- # Keywords that suggest ML/AI topics (used when category is not available)
278
- _ML_KEYWORDS = {
279
- "neural", "network", "transformer", "attention", "embedding", "gradient",
280
- "backpropagation", "cnn", "rnn", "lstm", "gpt", "bert", "diffusion",
281
- "reinforcement", "generative", "discriminative", "autoencoder", "vae",
282
- "gan", "fine-tuning", "pretraining", "tokenizer", "llm", "rlhf",
283
- "classification", "regression", "clustering", "deep learning",
284
- "machine learning", "optimization", "sgd", "adam", "batch normalization",
285
- }
286
-
287
-
288
- def _is_ml_topic(query: str) -> bool:
289
- """Heuristic: does the query look like an ML/AI topic?"""
290
- query_lower = query.lower()
291
- return any(kw in query_lower for kw in _ML_KEYWORDS)
292
-
293
-
294
- async def search(query: str, category_hint: str = "") -> str:
295
- """Route a search query to the best source.
296
-
297
- The agent can override by prefixing the query:
298
- - "hf: attention mechanism" -> HF Papers only
299
- - "wiki: merge sort" -> Wikipedia only
300
-
301
- Otherwise, uses keyword heuristic to route ML topics to HF Papers
302
- and everything else to Wikipedia.
303
- """
304
- query = query.strip()
305
-
306
- # Explicit source prefix
307
- lower = query.lower()
308
- if lower.startswith("hf:"):
309
- return await search_hf_papers(query[3:].strip())
310
- if lower.startswith("wiki:"):
311
- return await search_wikipedia(query[5:].strip())
312
-
313
- # Auto-route based on keyword heuristic
314
- if _is_ml_topic(query) or _is_ml_topic(category_hint):
315
- hf = await search_hf_papers(query)
316
- if "error" in hf.lower() or "no hf papers" in hf.lower():
317
- return await search_wikipedia(query)
318
- return hf
319
-
320
- # Default: Wikipedia
321
- return await search_wikipedia(query)
 
1
+ """Compatibility wrapper for the structured research tool layer.
2
 
3
+ New code should use ``explainer_env.research.run_research_tool`` directly.
 
 
 
 
 
 
 
4
  """
5
 
6
  from __future__ import annotations
7
 
8
+ try:
9
+ from ..research import run_research_tool
10
+ except ImportError: # pragma: no cover - supports direct test execution
11
+ from research import run_research_tool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
 
 
 
 
 
13
 
14
+ async def search(
15
+ query: str,
16
+ category_hint: str = "",
17
+ difficulty: str = "easy",
18
+ tool: str | None = None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  ) -> str:
20
+ """Run a research tool and render its result as text."""
21
+ selected_tool = tool or _default_tool(query, category_hint, difficulty)
22
+ result = await run_research_tool(selected_tool, query, category_hint)
23
+ return result.render()
24
+
25
+
26
+ def _default_tool(query: str, category_hint: str, difficulty: str) -> str:
27
+ text = f"{query} {category_hint}".lower()
28
+ if any(term in text for term in ("api", "code", "plot", "marimo", "manim")):
29
+ return "fetch_docs"
30
+ if any(term in text for term in ("model", "dataset", "hugging face", "hf hub")):
31
+ return "search_hf_hub"
32
+ if difficulty == "hard":
33
+ return "search_arxiv"
34
+ return "search_wikipedia"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/app.py CHANGED
@@ -58,7 +58,7 @@ def main(host: str = "0.0.0.0", port: int = 8000):
58
  This function enables running the server without Docker:
59
  uv run --project . server
60
  uv run --project . server --port 8001
61
- python -m openspiel_env.server.app
62
 
63
  Args:
64
  host: Host address to bind to (default: "0.0.0.0")
 
58
  This function enables running the server without Docker:
59
  uv run --project . server
60
  uv run --project . server --port 8001
61
+ python -m explainer_env.server.app
62
 
63
  Args:
64
  host: Host address to bind to (default: "0.0.0.0")
server/explainer_env_environment.py CHANGED
@@ -1,10 +1,11 @@
1
  """
2
- Research Interactive Explainer Environment (multi-step, async).
3
 
4
  Episode flow:
5
  1. reset() → agent gets a topic + tier
6
- 2. step(explore) × 1..MAX_EXPLORE → agent searches, gets papers back
7
- 3. step(generate) × 1 → agent produces marimo/manim code → episode ends
 
8
 
9
  Each step returns a per-step reward. The final generate step also includes
10
  a generation reward that accounts for how well the code uses the research.
@@ -20,22 +21,22 @@ from openenv.core.env_server.interfaces import Environment
20
  from openenv.core.env_server.types import State
21
 
22
  try:
 
23
  from ..models import ExplainerAction, ExplainerObservation
 
24
  from ..rewards.exploration import compute_explore_reward
25
- from ..rewards.generation import compute_generate_reward
26
- from ..rewards.sandbox import ast_parses, run_manim, run_marimo
27
- from ..rewards.sources import search as search_sources
28
  from ..task_bank import ALL_TASKS, EASY_TASKS, HARD_TASKS, MEDIUM_TASKS, Task
29
  except ImportError:
 
30
  from models import ExplainerAction, ExplainerObservation
 
31
  from rewards.exploration import compute_explore_reward
32
- from rewards.generation import compute_generate_reward
33
- from rewards.sandbox import ast_parses, run_manim, run_marimo
34
- from rewards.sources import search as search_sources
35
  from task_bank import ALL_TASKS, EASY_TASKS, HARD_TASKS, MEDIUM_TASKS, Task
36
 
37
- MAX_EXPLORE_STEPS = 3
38
-
39
 
40
  class ExplainerEnvironment(Environment):
41
  """
@@ -56,7 +57,16 @@ class ExplainerEnvironment(Environment):
56
  self._current_task: Task | None = None
57
  self._difficulty_pool: list[Task] = EASY_TASKS
58
  self._accumulated_context: list[str] = []
 
59
  self._explore_steps: int = 0
 
 
 
 
 
 
 
 
60
 
61
  # ------------------------------------------------------------------
62
  # Sync interface (fallback — OpenEnv prefers async when overridden)
@@ -78,6 +88,14 @@ class ExplainerEnvironment(Environment):
78
  done=True,
79
  reward=-1.0,
80
  )
 
 
 
 
 
 
 
 
81
 
82
  try:
83
  if action.action_type == "explore":
@@ -85,6 +103,8 @@ class ExplainerEnvironment(Environment):
85
  return asyncio.run(self._handle_explore(action, task))
86
  elif action.action_type == "generate":
87
  return self._handle_generate(action, task)
 
 
88
  else:
89
  return self._make_obs(
90
  task,
@@ -121,12 +141,22 @@ class ExplainerEnvironment(Environment):
121
  done=True,
122
  reward=-1.0,
123
  )
 
 
 
 
 
 
 
 
124
 
125
  try:
126
  if action.action_type == "explore":
127
  return await self._handle_explore(action, task)
128
  elif action.action_type == "generate":
129
  return self._handle_generate(action, task)
 
 
130
  else:
131
  return self._make_obs(
132
  task,
@@ -154,20 +184,40 @@ class ExplainerEnvironment(Environment):
154
  episode_id=episode_id or str(uuid4()), step_count=0
155
  )
156
  self._accumulated_context = []
 
157
  self._explore_steps = 0
158
-
159
- difficulty = kwargs.get("difficulty", None)
160
- if difficulty == "medium":
161
- pool = MEDIUM_TASKS
162
- elif difficulty == "hard":
163
- pool = HARD_TASKS
164
- elif difficulty == "easy":
165
- pool = EASY_TASKS
 
 
 
 
 
 
 
 
 
 
 
166
  else:
167
- pool = self._difficulty_pool
 
 
 
 
 
 
 
 
168
 
169
- rng = random.Random(seed) if seed is not None else random.Random()
170
- self._current_task = rng.choice(pool) if pool else rng.choice(ALL_TASKS)
171
 
172
  t = self._current_task
173
  return ExplainerObservation(
@@ -177,17 +227,31 @@ class ExplainerEnvironment(Environment):
177
  keywords=t.keywords,
178
  data_available=t.data_available,
179
  phase="explore",
180
- feedback="Research phase: search for relevant papers before generating.",
 
 
 
181
  search_results="",
182
  explored_context="",
183
  explore_steps_left=MAX_EXPLORE_STEPS,
 
 
184
  done=False,
185
  reward=0.0,
186
  )
187
 
188
  async def _handle_explore(self, action: ExplainerAction, task: Task) -> ExplainerObservation:
189
- """Process an explore action: search HF Papers/Wikipedia, score query."""
 
 
 
 
 
 
 
 
190
  if self._explore_steps >= MAX_EXPLORE_STEPS:
 
191
  return self._make_obs(
192
  task,
193
  phase="generate",
@@ -197,6 +261,8 @@ class ExplainerEnvironment(Environment):
197
 
198
  self._explore_steps += 1
199
  query = action.query.strip()
 
 
200
 
201
  if not query:
202
  return self._make_obs(
@@ -206,39 +272,68 @@ class ExplainerEnvironment(Environment):
206
  reward=0.0,
207
  )
208
 
209
- # Search HF Papers / Wikipedia (async, routed by keyword heuristic)
210
- results_text = await search_sources(query, category_hint=task.topic)
211
- self._accumulated_context.append(results_text)
 
 
 
 
 
212
 
213
  # Compute per-step exploration reward
214
  reward, components = compute_explore_reward(
215
  query=query,
216
- result_text=results_text,
 
 
217
  topic=task.topic,
218
  keywords_csv=task.keywords,
219
  task_content=task.content,
 
 
220
  accumulated_context=self._accumulated_context,
 
221
  )
222
 
223
  steps_left = MAX_EXPLORE_STEPS - self._explore_steps
224
- if steps_left > 0:
225
  phase = "explore"
226
- hint = f"{steps_left} explore step(s) left. Continue researching or generate."
 
 
 
227
  else:
228
  phase = "generate"
229
- hint = "Max explore steps reached. You must now generate."
 
230
 
231
  return self._make_obs(
232
  task,
233
  phase=phase,
234
- feedback=f"{hint}\nReward: {components}",
235
  search_results=results_text,
236
  reward=reward,
237
- metadata={"step": self._state.step_count, "phase": "explore", **components},
 
 
 
 
 
 
 
238
  )
239
 
240
  def _handle_generate(self, action: ExplainerAction, task: Task) -> ExplainerObservation:
241
- """Process a generate action: run sandbox, compute generation reward."""
 
 
 
 
 
 
 
 
242
  fmt = action.format or "marimo"
243
  code = action.code
244
  narration = action.narration
@@ -251,17 +346,7 @@ class ExplainerEnvironment(Environment):
251
  skip_penalty = 0.0
252
  penalty_msg = ""
253
 
254
- # Sandbox execution
255
- parses = ast_parses(code)
256
- exec_success = False
257
- exec_msg = ""
258
- if parses:
259
- if fmt == "marimo":
260
- exec_success, exec_msg = run_marimo(code)
261
- elif fmt == "manim":
262
- exec_success, exec_msg = run_manim(code)
263
- else:
264
- exec_msg = "Code has syntax errors and cannot be parsed."
265
 
266
  # Generation reward
267
  reward, components = compute_generate_reward(
@@ -269,35 +354,129 @@ class ExplainerEnvironment(Environment):
269
  fmt=fmt,
270
  narration=narration,
271
  task=task,
272
- exec_success=exec_success,
273
  accumulated_context=self._accumulated_context,
274
  )
275
  reward = max(0.0, reward + skip_penalty)
276
 
 
 
 
 
 
 
277
  # Feedback
278
  parts = []
279
  if penalty_msg:
280
  parts.append(penalty_msg)
281
- if not parses:
282
  parts.append("SYNTAX ERROR: code does not parse.")
283
- elif not exec_success:
284
- parts.append(f"EXECUTION FAILED: {exec_msg}")
285
  else:
286
- parts.append(f"EXECUTION OK: {exec_msg}")
287
  parts.append(
288
  f"Reward: {', '.join(f'{k}={v}' for k, v in components.items())}"
289
  )
290
 
 
 
 
 
 
 
 
291
  return self._make_obs(
292
  task,
293
- phase="done",
294
  feedback="\n".join(parts),
295
  reward=reward,
296
- done=True,
 
297
  metadata={
298
  "step": self._state.step_count,
299
  "phase": "generate",
300
  "explore_steps_used": self._explore_steps,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  **components,
302
  },
303
  )
@@ -311,6 +490,7 @@ class ExplainerEnvironment(Environment):
311
  reward: float = 0.0,
312
  done: bool = False,
313
  search_results: str = "",
 
314
  metadata: dict | None = None,
315
  ) -> ExplainerObservation:
316
  """Helper to build a consistent observation."""
@@ -325,6 +505,9 @@ class ExplainerEnvironment(Environment):
325
  search_results=search_results,
326
  explored_context="\n---\n".join(self._accumulated_context),
327
  explore_steps_left=MAX_EXPLORE_STEPS - self._explore_steps,
 
 
 
328
  done=done,
329
  reward=reward,
330
  metadata=metadata or {},
 
1
  """
2
+ Research -> Interactive Explainer Environment (multi-step, async).
3
 
4
  Episode flow:
5
  1. reset() → agent gets a topic + tier
6
+ 2. step(explore) × 0..MAX_EXPLORE → agent calls research tools
7
+ 3. step(generate) × 1 → agent produces marimo/manim code
8
+ 4. step(repair) × 0..MAX_REPAIR → agent fixes lint/build errors if needed
9
 
10
  Each step returns a per-step reward. The final generate step also includes
11
  a generation reward that accounts for how well the code uses the research.
 
21
  from openenv.core.env_server.types import State
22
 
23
  try:
24
+ from ..constants import MAX_EXPLORE_STEPS, MAX_REPAIR_STEPS
25
  from ..models import ExplainerAction, ExplainerObservation
26
+ from ..research import AVAILABLE_TOOLS, run_research_tool
27
  from ..rewards.exploration import compute_explore_reward
28
+ from ..rewards.generation import adjust_repair_reward, compute_generate_reward
29
+ from ..rewards.sandbox import validate_code
 
30
  from ..task_bank import ALL_TASKS, EASY_TASKS, HARD_TASKS, MEDIUM_TASKS, Task
31
  except ImportError:
32
+ from constants import MAX_EXPLORE_STEPS, MAX_REPAIR_STEPS
33
  from models import ExplainerAction, ExplainerObservation
34
+ from research import AVAILABLE_TOOLS, run_research_tool
35
  from rewards.exploration import compute_explore_reward
36
+ from rewards.generation import adjust_repair_reward, compute_generate_reward
37
+ from rewards.sandbox import validate_code
 
38
  from task_bank import ALL_TASKS, EASY_TASKS, HARD_TASKS, MEDIUM_TASKS, Task
39
 
 
 
40
 
41
  class ExplainerEnvironment(Environment):
42
  """
 
57
  self._current_task: Task | None = None
58
  self._difficulty_pool: list[Task] = EASY_TASKS
59
  self._accumulated_context: list[str] = []
60
+ self._used_tools: set[str] = set()
61
  self._explore_steps: int = 0
62
+ self._repair_steps: int = 0
63
+ self._phase: str = "explore"
64
+ self._done: bool = False
65
+ self._last_code: str = ""
66
+ self._last_format: str = "marimo"
67
+ self._last_narration: str = ""
68
+ self._last_errors: str = ""
69
+ self._last_error_codes: list[str] = []
70
 
71
  # ------------------------------------------------------------------
72
  # Sync interface (fallback — OpenEnv prefers async when overridden)
 
88
  done=True,
89
  reward=-1.0,
90
  )
91
+ if self._done:
92
+ return self._make_obs(
93
+ task,
94
+ phase="done",
95
+ feedback="Episode is already done. Call reset() to start a new one.",
96
+ reward=0.0,
97
+ done=True,
98
+ )
99
 
100
  try:
101
  if action.action_type == "explore":
 
103
  return asyncio.run(self._handle_explore(action, task))
104
  elif action.action_type == "generate":
105
  return self._handle_generate(action, task)
106
+ elif action.action_type == "repair":
107
+ return self._handle_repair(action, task)
108
  else:
109
  return self._make_obs(
110
  task,
 
141
  done=True,
142
  reward=-1.0,
143
  )
144
+ if self._done:
145
+ return self._make_obs(
146
+ task,
147
+ phase="done",
148
+ feedback="Episode is already done. Call reset() to start a new one.",
149
+ reward=0.0,
150
+ done=True,
151
+ )
152
 
153
  try:
154
  if action.action_type == "explore":
155
  return await self._handle_explore(action, task)
156
  elif action.action_type == "generate":
157
  return self._handle_generate(action, task)
158
+ elif action.action_type == "repair":
159
+ return self._handle_repair(action, task)
160
  else:
161
  return self._make_obs(
162
  task,
 
184
  episode_id=episode_id or str(uuid4()), step_count=0
185
  )
186
  self._accumulated_context = []
187
+ self._used_tools = set()
188
  self._explore_steps = 0
189
+ self._repair_steps = 0
190
+ self._phase = "explore"
191
+ self._done = False
192
+ self._last_code = ""
193
+ self._last_format = "marimo"
194
+ self._last_narration = ""
195
+ self._last_errors = ""
196
+ self._last_error_codes = []
197
+
198
+ # Allow selecting a specific task by topic name
199
+ topic = kwargs.get("topic", None)
200
+ if topic:
201
+ match = next((t for t in ALL_TASKS if t.topic == topic), None)
202
+ if match:
203
+ self._current_task = match
204
+ else:
205
+ # Fallback to random if topic not found
206
+ rng = random.Random(seed) if seed is not None else random.Random()
207
+ self._current_task = rng.choice(ALL_TASKS)
208
  else:
209
+ difficulty = kwargs.get("difficulty", None)
210
+ if difficulty == "medium":
211
+ pool = MEDIUM_TASKS
212
+ elif difficulty == "hard":
213
+ pool = HARD_TASKS
214
+ elif difficulty == "easy":
215
+ pool = EASY_TASKS
216
+ else:
217
+ pool = self._difficulty_pool
218
 
219
+ rng = random.Random(seed) if seed is not None else random.Random()
220
+ self._current_task = rng.choice(pool) if pool else rng.choice(ALL_TASKS)
221
 
222
  t = self._current_task
223
  return ExplainerObservation(
 
227
  keywords=t.keywords,
228
  data_available=t.data_available,
229
  phase="explore",
230
+ feedback=(
231
+ "Research phase: choose a tool and query relevant to the topic. "
232
+ f"Available tools: {', '.join(AVAILABLE_TOOLS)}."
233
+ ),
234
  search_results="",
235
  explored_context="",
236
  explore_steps_left=MAX_EXPLORE_STEPS,
237
+ repair_attempts_left=MAX_REPAIR_STEPS,
238
+ available_tools=list(AVAILABLE_TOOLS),
239
  done=False,
240
  reward=0.0,
241
  )
242
 
243
  async def _handle_explore(self, action: ExplainerAction, task: Task) -> ExplainerObservation:
244
+ """Process an explore action: call a research tool and score the result."""
245
+ if self._phase not in {"explore", "generate"}:
246
+ return self._make_obs(
247
+ task,
248
+ phase=self._phase,
249
+ feedback=f"Cannot explore during phase '{self._phase}'.",
250
+ reward=0.0,
251
+ )
252
+
253
  if self._explore_steps >= MAX_EXPLORE_STEPS:
254
+ self._phase = "generate"
255
  return self._make_obs(
256
  task,
257
  phase="generate",
 
261
 
262
  self._explore_steps += 1
263
  query = action.query.strip()
264
+ intent = action.intent.strip()
265
+ tool = action.tool or "search_wikipedia"
266
 
267
  if not query:
268
  return self._make_obs(
 
272
  reward=0.0,
273
  )
274
 
275
+ previous_context = list(self._accumulated_context)
276
+ used_tools = set(self._used_tools)
277
+
278
+ result = await run_research_tool(tool, query, intent)
279
+ results_text = result.render()
280
+ if result.ok:
281
+ self._accumulated_context.append(result.text)
282
+ self._used_tools.add(tool)
283
 
284
  # Compute per-step exploration reward
285
  reward, components = compute_explore_reward(
286
  query=query,
287
+ tool=tool,
288
+ intent=intent,
289
+ result=result,
290
  topic=task.topic,
291
  keywords_csv=task.keywords,
292
  task_content=task.content,
293
+ difficulty=task.difficulty,
294
+ previous_context=previous_context,
295
  accumulated_context=self._accumulated_context,
296
+ used_tools=used_tools,
297
  )
298
 
299
  steps_left = MAX_EXPLORE_STEPS - self._explore_steps
300
+ if steps_left > 1:
301
  phase = "explore"
302
+ hint = f"Research going well — {steps_left} more steps available. Keep searching or move to generation."
303
+ elif steps_left == 1:
304
+ phase = "explore"
305
+ hint = "Last research step available. Search for any missing context, or proceed to generate."
306
  else:
307
  phase = "generate"
308
+ hint = "Research phase complete. Time to generate your explanation."
309
+ self._phase = phase
310
 
311
  return self._make_obs(
312
  task,
313
  phase=phase,
314
+ feedback=f"{hint}\nTool: {tool}\nReward: {components}",
315
  search_results=results_text,
316
  reward=reward,
317
+ metadata={
318
+ "step": self._state.step_count,
319
+ "phase": "explore",
320
+ "tool": tool,
321
+ "source_count": len(result.chunks),
322
+ "error": result.error,
323
+ **components,
324
+ },
325
  )
326
 
327
  def _handle_generate(self, action: ExplainerAction, task: Task) -> ExplainerObservation:
328
+ """Process a generate action: run sandbox, maybe open repair phase."""
329
+ if self._phase not in {"explore", "generate"}:
330
+ return self._make_obs(
331
+ task,
332
+ phase=self._phase,
333
+ feedback=f"Cannot generate during phase '{self._phase}'.",
334
+ reward=0.0,
335
+ )
336
+
337
  fmt = action.format or "marimo"
338
  code = action.code
339
  narration = action.narration
 
346
  skip_penalty = 0.0
347
  penalty_msg = ""
348
 
349
+ sandbox = validate_code(fmt, code)
 
 
 
 
 
 
 
 
 
 
350
 
351
  # Generation reward
352
  reward, components = compute_generate_reward(
 
354
  fmt=fmt,
355
  narration=narration,
356
  task=task,
357
+ exec_success=sandbox.exec_success,
358
  accumulated_context=self._accumulated_context,
359
  )
360
  reward = max(0.0, reward + skip_penalty)
361
 
362
+ self._last_code = code
363
+ self._last_format = fmt
364
+ self._last_narration = narration
365
+ self._last_errors = sandbox.render_errors()
366
+ self._last_error_codes = sandbox.error_codes
367
+
368
  # Feedback
369
  parts = []
370
  if penalty_msg:
371
  parts.append(penalty_msg)
372
+ if not sandbox.parses:
373
  parts.append("SYNTAX ERROR: code does not parse.")
374
+ elif not sandbox.exec_success:
375
+ parts.append(f"EXECUTION FAILED: {sandbox.render_errors()}")
376
  else:
377
+ parts.append(f"EXECUTION OK: {sandbox.message}")
378
  parts.append(
379
  f"Reward: {', '.join(f'{k}={v}' for k, v in components.items())}"
380
  )
381
 
382
+ done = sandbox.exec_success or self._repair_steps >= MAX_REPAIR_STEPS
383
+ phase = "done" if done else "repair"
384
+ self._phase = phase
385
+ self._done = done
386
+ if not done:
387
+ parts.append("Repair phase: submit one revised artifact using the error feedback.")
388
+
389
  return self._make_obs(
390
  task,
391
+ phase=phase,
392
  feedback="\n".join(parts),
393
  reward=reward,
394
+ done=done,
395
+ last_errors="" if sandbox.exec_success else sandbox.render_errors(),
396
  metadata={
397
  "step": self._state.step_count,
398
  "phase": "generate",
399
  "explore_steps_used": self._explore_steps,
400
+ "sandbox_message": sandbox.message,
401
+ "error_codes": sandbox.error_codes,
402
+ **components,
403
+ },
404
+ )
405
+
406
+ def _handle_repair(self, action: ExplainerAction, task: Task) -> ExplainerObservation:
407
+ """Process one repair attempt after a failed generate action."""
408
+ if self._phase != "repair":
409
+ return self._make_obs(
410
+ task,
411
+ phase=self._phase,
412
+ feedback="Repair is only available after a failed generate step.",
413
+ reward=0.0,
414
+ done=self._done,
415
+ )
416
+ if self._repair_steps >= MAX_REPAIR_STEPS:
417
+ self._phase = "done"
418
+ self._done = True
419
+ return self._make_obs(
420
+ task,
421
+ phase="done",
422
+ feedback="No repair attempts left.",
423
+ reward=0.0,
424
+ done=True,
425
+ )
426
+
427
+ self._repair_steps += 1
428
+ fmt = action.format or self._last_format or "marimo"
429
+ code = action.code
430
+ narration = action.narration or self._last_narration
431
+ previous_code = self._last_code
432
+ previous_errors = list(self._last_error_codes)
433
+
434
+ sandbox = validate_code(fmt, code)
435
+ base_reward, components = compute_generate_reward(
436
+ code=code,
437
+ fmt=fmt,
438
+ narration=narration,
439
+ task=task,
440
+ exec_success=sandbox.exec_success,
441
+ accumulated_context=self._accumulated_context,
442
+ )
443
+ repair_reward, repair_components = adjust_repair_reward(
444
+ base_reward,
445
+ repair_success=sandbox.exec_success,
446
+ previous_error_codes=previous_errors,
447
+ new_error_codes=sandbox.error_codes,
448
+ previous_code=previous_code,
449
+ repaired_code=code,
450
+ )
451
+ components.update(repair_components)
452
+
453
+ self._last_code = code
454
+ self._last_format = fmt
455
+ self._last_narration = narration
456
+ self._last_errors = sandbox.render_errors()
457
+ self._last_error_codes = sandbox.error_codes
458
+ self._phase = "done"
459
+ self._done = True
460
+
461
+ status = "REPAIR OK" if sandbox.exec_success else "REPAIR FAILED"
462
+ feedback = (
463
+ f"{status}: {sandbox.message if sandbox.exec_success else sandbox.render_errors()}\n"
464
+ f"Reward: {', '.join(f'{k}={v}' for k, v in components.items())}"
465
+ )
466
+ return self._make_obs(
467
+ task,
468
+ phase="done",
469
+ feedback=feedback,
470
+ reward=repair_reward,
471
+ done=True,
472
+ last_errors="" if sandbox.exec_success else sandbox.render_errors(),
473
+ metadata={
474
+ "step": self._state.step_count,
475
+ "phase": "repair",
476
+ "explore_steps_used": self._explore_steps,
477
+ "repair_steps_used": self._repair_steps,
478
+ "sandbox_message": sandbox.message,
479
+ "error_codes": sandbox.error_codes,
480
  **components,
481
  },
482
  )
 
490
  reward: float = 0.0,
491
  done: bool = False,
492
  search_results: str = "",
493
+ last_errors: str | None = None,
494
  metadata: dict | None = None,
495
  ) -> ExplainerObservation:
496
  """Helper to build a consistent observation."""
 
505
  search_results=search_results,
506
  explored_context="\n---\n".join(self._accumulated_context),
507
  explore_steps_left=MAX_EXPLORE_STEPS - self._explore_steps,
508
+ repair_attempts_left=MAX_REPAIR_STEPS - self._repair_steps,
509
+ last_errors=self._last_errors if last_errors is None else last_errors,
510
+ available_tools=list(AVAILABLE_TOOLS),
511
  done=done,
512
  reward=reward,
513
  metadata=metadata or {},
tests/test_client_server.py CHANGED
@@ -42,7 +42,12 @@ def run_tests(base_url: str):
42
  print(f" reset: topic={obs.topic!r}, phase={obs.phase}")
43
 
44
  # --- explore ---
45
- action = ExplainerAction(action_type="explore", query=obs.topic)
 
 
 
 
 
46
  result = sc.step(action)
47
  assert not result.done
48
  assert result.observation.explore_steps_left == 2
@@ -55,6 +60,12 @@ def run_tests(base_url: str):
55
  code="import marimo as mo\napp = mo.App()\n@app.cell\ndef _():\n mo.md('hi')\n return\n",
56
  )
57
  result = sc.step(action)
 
 
 
 
 
 
58
  assert result.done
59
  assert isinstance(result.reward, (int, float))
60
  print(f" generate: reward={result.reward:.3f}, done={result.done}")
@@ -75,13 +86,15 @@ def main():
75
  if args.url:
76
  run_tests(args.url)
77
  else:
 
78
  proc = subprocess.Popen(
79
- ["uv", "run", "server"],
 
80
  stdout=subprocess.PIPE,
81
  stderr=subprocess.PIPE,
82
  )
83
  try:
84
- url = "http://localhost:8000"
85
  if not wait_for_server(url):
86
  stderr = proc.stderr.read().decode() if proc.stderr else ""
87
  print(f"FAIL: server did not start\n{stderr}", file=sys.stderr)
 
42
  print(f" reset: topic={obs.topic!r}, phase={obs.phase}")
43
 
44
  # --- explore ---
45
+ action = ExplainerAction(
46
+ action_type="explore",
47
+ tool="search_wikipedia",
48
+ query=obs.topic,
49
+ intent="overview",
50
+ )
51
  result = sc.step(action)
52
  assert not result.done
53
  assert result.observation.explore_steps_left == 2
 
60
  code="import marimo as mo\napp = mo.App()\n@app.cell\ndef _():\n mo.md('hi')\n return\n",
61
  )
62
  result = sc.step(action)
63
+ if not result.done:
64
+ result = sc.step(ExplainerAction(
65
+ action_type="repair",
66
+ format="marimo",
67
+ code="import marimo as mo\napp = mo.App()\n@app.cell\ndef _():\n mo.md('hi')\n return\n",
68
+ ))
69
  assert result.done
70
  assert isinstance(result.reward, (int, float))
71
  print(f" generate: reward={result.reward:.3f}, done={result.done}")
 
86
  if args.url:
87
  run_tests(args.url)
88
  else:
89
+ port = "8010"
90
  proc = subprocess.Popen(
91
+ ["uv", "run", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", port],
92
+ cwd=str(Path(__file__).resolve().parents[1]),
93
  stdout=subprocess.PIPE,
94
  stderr=subprocess.PIPE,
95
  )
96
  try:
97
+ url = f"http://localhost:{port}"
98
  if not wait_for_server(url):
99
  stderr = proc.stderr.read().decode() if proc.stderr else ""
100
  print(f"FAIL: server did not start\n{stderr}", file=sys.stderr)
tests/test_docker.py CHANGED
@@ -76,10 +76,17 @@ def run_tests(base_url: str):
76
  print(f" reset: topic={result.observation.topic!r}")
77
 
78
  action = ExplainerAction(
 
79
  format="marimo",
80
  code="import marimo as mo\napp = mo.App()\n@app.cell\ndef _():\n return\n",
81
  )
82
  result = sc.step(action)
 
 
 
 
 
 
83
  assert isinstance(result.reward, (int, float))
84
  print(f" step: reward={result.reward:.3f}, done={result.done}")
85
 
 
76
  print(f" reset: topic={result.observation.topic!r}")
77
 
78
  action = ExplainerAction(
79
+ action_type="generate",
80
  format="marimo",
81
  code="import marimo as mo\napp = mo.App()\n@app.cell\ndef _():\n return\n",
82
  )
83
  result = sc.step(action)
84
+ if not result.done:
85
+ result = sc.step(ExplainerAction(
86
+ action_type="repair",
87
+ format="marimo",
88
+ code="import marimo as mo\napp = mo.App()\n@app.cell\ndef _():\n return\n",
89
+ ))
90
  assert isinstance(result.reward, (int, float))
91
  print(f" step: reward={result.reward:.3f}, done={result.done}")
92
 
tests/test_environment.py CHANGED
@@ -29,7 +29,12 @@ def test_reset_deterministic_with_seed():
29
  def test_explore_step():
30
  env = ExplainerEnvironment()
31
  env.reset(seed=1)
32
- action = ExplainerAction(action_type="explore", query="gradient descent optimization")
 
 
 
 
 
33
  obs = env.step(action)
34
  assert obs.done is False
35
  assert obs.explore_steps_left == 2
@@ -40,7 +45,7 @@ def test_explore_step():
40
  def test_explore_empty_query():
41
  env = ExplainerEnvironment()
42
  env.reset(seed=1)
43
- action = ExplainerAction(action_type="explore", query="")
44
  obs = env.step(action)
45
  assert obs.reward == 0.0
46
  assert "Empty query" in obs.feedback
@@ -50,7 +55,11 @@ def test_explore_max_steps():
50
  env = ExplainerEnvironment()
51
  env.reset(seed=1)
52
  for i in range(3):
53
- obs = env.step(ExplainerAction(action_type="explore", query=f"search {i}"))
 
 
 
 
54
  assert obs.phase == "generate"
55
  assert obs.explore_steps_left == 0
56
 
@@ -59,17 +68,20 @@ def test_explore_then_generate():
59
  env = ExplainerEnvironment()
60
  env.reset(seed=1)
61
  # Explore
62
- obs = env.step(ExplainerAction(action_type="explore", query="gradient descent"))
 
 
 
 
63
  assert obs.done is False
64
- assert obs.explored_context != ""
65
  # Generate
66
  obs = env.step(ExplainerAction(
67
  action_type="generate",
68
  format="marimo",
69
  code="import marimo as mo\napp = mo.App()\n@app.cell\ndef _():\n return\n",
70
  ))
71
- assert obs.done is True
72
- assert obs.phase == "done"
73
  assert isinstance(obs.reward, (int, float))
74
 
75
 
@@ -81,13 +93,14 @@ def test_generate_without_explore_penalty():
81
  format="marimo",
82
  code="x = 1",
83
  ))
84
- assert obs.done is True
 
85
  assert "penalty" in obs.feedback.lower() or "without" in obs.feedback.lower()
86
 
87
 
88
  def test_step_without_reset():
89
  env = ExplainerEnvironment()
90
- action = ExplainerAction(action_type="explore", query="test")
91
  obs = env.step(action)
92
  assert obs.done is True
93
  assert obs.reward == -1.0
@@ -96,7 +109,11 @@ def test_step_without_reset():
96
  def test_generate_reward_in_metadata():
97
  env = ExplainerEnvironment()
98
  env.reset(seed=1)
99
- env.step(ExplainerAction(action_type="explore", query="gradient descent"))
 
 
 
 
100
  obs = env.step(ExplainerAction(
101
  action_type="generate",
102
  format="marimo",
@@ -120,7 +137,11 @@ def test_step_increments_count():
120
  env = ExplainerEnvironment()
121
  env.reset(seed=1)
122
  assert env.state.step_count == 0
123
- env.step(ExplainerAction(action_type="explore", query="test"))
 
 
 
 
124
  assert env.state.step_count == 1
125
  env.step(ExplainerAction(action_type="generate", format="marimo", code="x=1"))
126
  assert env.state.step_count == 2
@@ -134,10 +155,30 @@ def test_bad_code_does_not_crash():
134
  format="marimo",
135
  code=")))syntax error(((",
136
  ))
137
- assert obs.done is True
 
138
  assert "SYNTAX ERROR" in obs.feedback
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  if __name__ == "__main__":
142
  tests = [
143
  test_reset_returns_observation,
@@ -152,6 +193,7 @@ if __name__ == "__main__":
152
  test_state_episode_id_changes,
153
  test_step_increments_count,
154
  test_bad_code_does_not_crash,
 
155
  ]
156
  passed = 0
157
  for t in tests:
 
29
  def test_explore_step():
30
  env = ExplainerEnvironment()
31
  env.reset(seed=1)
32
+ action = ExplainerAction(
33
+ action_type="explore",
34
+ tool="search_wikipedia",
35
+ query="gradient descent optimization",
36
+ intent="beginner explanation",
37
+ )
38
  obs = env.step(action)
39
  assert obs.done is False
40
  assert obs.explore_steps_left == 2
 
45
  def test_explore_empty_query():
46
  env = ExplainerEnvironment()
47
  env.reset(seed=1)
48
+ action = ExplainerAction(action_type="explore", tool="search_wikipedia", query="")
49
  obs = env.step(action)
50
  assert obs.reward == 0.0
51
  assert "Empty query" in obs.feedback
 
55
  env = ExplainerEnvironment()
56
  env.reset(seed=1)
57
  for i in range(3):
58
+ obs = env.step(ExplainerAction(
59
+ action_type="explore",
60
+ tool="search_wikipedia",
61
+ query=f"search {i}",
62
+ ))
63
  assert obs.phase == "generate"
64
  assert obs.explore_steps_left == 0
65
 
 
68
  env = ExplainerEnvironment()
69
  env.reset(seed=1)
70
  # Explore
71
+ obs = env.step(ExplainerAction(
72
+ action_type="explore",
73
+ tool="search_wikipedia",
74
+ query="gradient descent",
75
+ ))
76
  assert obs.done is False
77
+ assert obs.search_results != ""
78
  # Generate
79
  obs = env.step(ExplainerAction(
80
  action_type="generate",
81
  format="marimo",
82
  code="import marimo as mo\napp = mo.App()\n@app.cell\ndef _():\n return\n",
83
  ))
84
+ assert obs.phase in ("repair", "done")
 
85
  assert isinstance(obs.reward, (int, float))
86
 
87
 
 
93
  format="marimo",
94
  code="x = 1",
95
  ))
96
+ assert obs.done is False
97
+ assert obs.phase == "repair"
98
  assert "penalty" in obs.feedback.lower() or "without" in obs.feedback.lower()
99
 
100
 
101
  def test_step_without_reset():
102
  env = ExplainerEnvironment()
103
+ action = ExplainerAction(action_type="explore", tool="search_wikipedia", query="test")
104
  obs = env.step(action)
105
  assert obs.done is True
106
  assert obs.reward == -1.0
 
109
  def test_generate_reward_in_metadata():
110
  env = ExplainerEnvironment()
111
  env.reset(seed=1)
112
+ env.step(ExplainerAction(
113
+ action_type="explore",
114
+ tool="search_wikipedia",
115
+ query="gradient descent",
116
+ ))
117
  obs = env.step(ExplainerAction(
118
  action_type="generate",
119
  format="marimo",
 
137
  env = ExplainerEnvironment()
138
  env.reset(seed=1)
139
  assert env.state.step_count == 0
140
+ env.step(ExplainerAction(
141
+ action_type="explore",
142
+ tool="search_wikipedia",
143
+ query="test",
144
+ ))
145
  assert env.state.step_count == 1
146
  env.step(ExplainerAction(action_type="generate", format="marimo", code="x=1"))
147
  assert env.state.step_count == 2
 
155
  format="marimo",
156
  code=")))syntax error(((",
157
  ))
158
+ assert obs.done is False
159
+ assert obs.phase == "repair"
160
  assert "SYNTAX ERROR" in obs.feedback
161
 
162
 
163
+ def test_repair_ends_episode():
164
+ env = ExplainerEnvironment()
165
+ env.reset(seed=1)
166
+ env.step(ExplainerAction(
167
+ action_type="generate",
168
+ format="marimo",
169
+ code="x = 1",
170
+ ))
171
+ obs = env.step(ExplainerAction(
172
+ action_type="repair",
173
+ format="marimo",
174
+ code="x = 2",
175
+ repair_notes="attempted fix",
176
+ ))
177
+ assert obs.done is True
178
+ assert obs.phase == "done"
179
+ assert obs.metadata["phase"] == "repair"
180
+
181
+
182
  if __name__ == "__main__":
183
  tests = [
184
  test_reset_returns_observation,
 
193
  test_state_episode_id_changes,
194
  test_step_increments_count,
195
  test_bad_code_does_not_crash,
196
+ test_repair_ends_episode,
197
  ]
198
  passed = 0
199
  for t in tests:
tests/test_models.py CHANGED
@@ -9,9 +9,16 @@ from models import ExplainerAction, ExplainerObservation
9
 
10
 
11
  def test_action_explore():
12
- a = ExplainerAction(action_type="explore", query="attention mechanism")
 
 
 
 
 
13
  assert a.action_type == "explore"
 
14
  assert a.query == "attention mechanism"
 
15
  assert a.code == ""
16
  assert a.format is None
17
 
@@ -38,12 +45,24 @@ def test_action_generate_manim():
38
  assert a.narration != ""
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
41
  def test_observation_defaults():
42
  obs = ExplainerObservation()
43
  assert obs.topic == ""
44
  assert obs.tier == "beginner"
45
  assert obs.phase == "explore"
46
  assert obs.explore_steps_left == 3
 
47
  assert obs.done is False
48
 
49
 
@@ -72,6 +91,7 @@ if __name__ == "__main__":
72
  test_action_explore()
73
  test_action_generate_marimo()
74
  test_action_generate_manim()
 
75
  test_observation_defaults()
76
  test_observation_full()
77
- print("PASS: test_models (5/5)")
 
9
 
10
 
11
  def test_action_explore():
12
+ a = ExplainerAction(
13
+ action_type="explore",
14
+ tool="search_arxiv",
15
+ query="attention mechanism",
16
+ intent="visual intuition",
17
+ )
18
  assert a.action_type == "explore"
19
+ assert a.tool == "search_arxiv"
20
  assert a.query == "attention mechanism"
21
+ assert a.intent == "visual intuition"
22
  assert a.code == ""
23
  assert a.format is None
24
 
 
45
  assert a.narration != ""
46
 
47
 
48
+ def test_action_repair():
49
+ a = ExplainerAction(
50
+ action_type="repair",
51
+ format="marimo",
52
+ code="x = 1",
53
+ repair_notes="fixed syntax",
54
+ )
55
+ assert a.action_type == "repair"
56
+ assert a.repair_notes == "fixed syntax"
57
+
58
+
59
  def test_observation_defaults():
60
  obs = ExplainerObservation()
61
  assert obs.topic == ""
62
  assert obs.tier == "beginner"
63
  assert obs.phase == "explore"
64
  assert obs.explore_steps_left == 3
65
+ assert obs.repair_attempts_left == 1
66
  assert obs.done is False
67
 
68
 
 
91
  test_action_explore()
92
  test_action_generate_marimo()
93
  test_action_generate_manim()
94
+ test_action_repair()
95
  test_observation_defaults()
96
  test_observation_full()
97
+ print("PASS: test_models (6/6)")
tests/test_rewards.py CHANGED
@@ -6,10 +6,13 @@ from pathlib import Path
6
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
 
8
  from rewards.exploration import (
 
9
  compute_explore_reward,
10
  query_relevance,
11
  research_breadth,
12
  result_novelty,
 
 
13
  )
14
  from rewards.generation import (
15
  compute_generate_reward,
@@ -20,6 +23,7 @@ from rewards.generation import (
20
  narration_score,
21
  )
22
  from rewards.sandbox import ast_parses
 
23
  from task_bank import ALL_TASKS
24
 
25
  MARIMO_TASK = next(t for t in ALL_TASKS if t.topic == "Linear Regression")
@@ -53,19 +57,73 @@ def test_research_breadth():
53
  assert research_breadth(["a", "b"], min_sources=2) == 1.0
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def test_explore_reward_integration():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  reward, comp = compute_explore_reward(
58
  query="linear regression least squares",
59
- result_text="Linear regression minimizes squared error...",
 
 
60
  topic="Linear Regression",
61
  keywords_csv="linear regression,least squares,MSE",
62
  task_content="Linear regression is a method for modeling the relationship between variables.",
 
 
63
  accumulated_context=["first search result"],
 
64
  )
65
  assert reward > 0.1
 
66
  assert "query_relevance" in comp
67
- assert "result_novelty" in comp
68
- assert "research_breadth" in comp
69
  assert "content_sufficiency" in comp
70
 
71
 
@@ -195,6 +253,9 @@ if __name__ == "__main__":
195
  test_query_relevance,
196
  test_result_novelty,
197
  test_research_breadth,
 
 
 
198
  test_explore_reward_integration,
199
  test_keyword_coverage,
200
  test_format_match,
 
6
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
 
8
  from rewards.exploration import (
9
+ coverage_delta,
10
  compute_explore_reward,
11
  query_relevance,
12
  research_breadth,
13
  result_novelty,
14
+ source_quality,
15
+ tool_choice_score,
16
  )
17
  from rewards.generation import (
18
  compute_generate_reward,
 
23
  narration_score,
24
  )
25
  from rewards.sandbox import ast_parses
26
+ from research.types import ResearchChunk, ResearchResult
27
  from task_bank import ALL_TASKS
28
 
29
  MARIMO_TASK = next(t for t in ALL_TASKS if t.topic == "Linear Regression")
 
57
  assert research_breadth(["a", "b"], min_sources=2) == 1.0
58
 
59
 
60
+ def test_tool_choice_score():
61
+ assert tool_choice_score("search_arxiv", "hard", "recent research paper") == 1.0
62
+ assert tool_choice_score("fetch_docs", "easy", "marimo plotting api") == 1.0
63
+
64
+
65
+ def test_source_quality():
66
+ result = ResearchResult(
67
+ tool="search_arxiv",
68
+ query="linear regression",
69
+ chunks=[
70
+ ResearchChunk(
71
+ source="arxiv",
72
+ tool="search_arxiv",
73
+ title="A paper",
74
+ url="https://arxiv.org/abs/1",
75
+ text="linear regression least squares optimization " * 10,
76
+ score=1.0,
77
+ metadata={"year": 2024},
78
+ )
79
+ ],
80
+ )
81
+ assert source_quality(result) > 0.7
82
+
83
+
84
+ def test_coverage_delta():
85
+ assert coverage_delta(
86
+ "linear regression,MSE",
87
+ "linear regression",
88
+ [],
89
+ "mean squared error MSE",
90
+ ) > 0.0
91
+
92
+
93
  def test_explore_reward_integration():
94
+ result = ResearchResult(
95
+ tool="search_wikipedia",
96
+ query="linear regression least squares",
97
+ chunks=[
98
+ ResearchChunk(
99
+ source="wikipedia",
100
+ tool="search_wikipedia",
101
+ title="Linear regression",
102
+ url="https://example.test",
103
+ text="Linear regression minimizes squared error with least squares.",
104
+ score=1.0,
105
+ metadata={"page": "Linear regression"},
106
+ )
107
+ ],
108
+ )
109
  reward, comp = compute_explore_reward(
110
  query="linear regression least squares",
111
+ tool="search_wikipedia",
112
+ intent="beginner explanation",
113
+ result=result,
114
  topic="Linear Regression",
115
  keywords_csv="linear regression,least squares,MSE",
116
  task_content="Linear regression is a method for modeling the relationship between variables.",
117
+ difficulty="easy",
118
+ previous_context=[],
119
  accumulated_context=["first search result"],
120
+ used_tools=set(),
121
  )
122
  assert reward > 0.1
123
+ assert "tool_choice" in comp
124
  assert "query_relevance" in comp
125
+ assert "source_quality" in comp
126
+ assert "coverage_delta" in comp
127
  assert "content_sufficiency" in comp
128
 
129
 
 
253
  test_query_relevance,
254
  test_result_novelty,
255
  test_research_breadth,
256
+ test_tool_choice_score,
257
+ test_source_quality,
258
+ test_coverage_delta,
259
  test_explore_reward_integration,
260
  test_keyword_coverage,
261
  test_format_match,
uv.lock CHANGED
The diff for this file is too large to render. See raw diff