forkscope 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. forkscope-0.1.0/.github/workflows/publish.yml +52 -0
  2. forkscope-0.1.0/.github/workflows/test.yml +32 -0
  3. forkscope-0.1.0/.gitignore +29 -0
  4. forkscope-0.1.0/.pre-commit-config.yaml +8 -0
  5. forkscope-0.1.0/.python-version +2 -0
  6. forkscope-0.1.0/LICENSE +22 -0
  7. forkscope-0.1.0/PKG-INFO +51 -0
  8. forkscope-0.1.0/README.md +27 -0
  9. forkscope-0.1.0/RELEASING.md +22 -0
  10. forkscope-0.1.0/examples/__init__.py +1 -0
  11. forkscope-0.1.0/examples/point_mass/README.md +52 -0
  12. forkscope-0.1.0/examples/point_mass/__init__.py +1 -0
  13. forkscope-0.1.0/examples/point_mass/config.yaml +14 -0
  14. forkscope-0.1.0/examples/point_mass/example.py +81 -0
  15. forkscope-0.1.0/pyproject.toml +53 -0
  16. forkscope-0.1.0/src/forkscope/__init__.py +7 -0
  17. forkscope-0.1.0/src/forkscope/benchmarker.py +57 -0
  18. forkscope-0.1.0/src/forkscope/cli.py +70 -0
  19. forkscope-0.1.0/src/forkscope/evaluation/__init__.py +1 -0
  20. forkscope-0.1.0/src/forkscope/evaluation/episode.py +57 -0
  21. forkscope-0.1.0/src/forkscope/evaluation/sweep.py +37 -0
  22. forkscope-0.1.0/src/forkscope/interface/__init__.py +1 -0
  23. forkscope-0.1.0/src/forkscope/interface/interfaces.py +27 -0
  24. forkscope-0.1.0/src/forkscope/interface/tree.py +252 -0
  25. forkscope-0.1.0/src/forkscope/planning/__init__.py +1 -0
  26. forkscope-0.1.0/src/forkscope/planning/planner.py +16 -0
  27. forkscope-0.1.0/src/forkscope/records/__init__.py +1 -0
  28. forkscope-0.1.0/src/forkscope/records/records.py +28 -0
  29. forkscope-0.1.0/src/forkscope/records/writer.py +30 -0
  30. forkscope-0.1.0/tests/.gitkeep +1 -0
  31. forkscope-0.1.0/tests/__init__.py +1 -0
  32. forkscope-0.1.0/tests/examples.py +53 -0
  33. forkscope-0.1.0/tests/helpers.py +56 -0
  34. forkscope-0.1.0/tests/test_benchmarker.py +212 -0
  35. forkscope-0.1.0/tests/test_cli.py +51 -0
  36. forkscope-0.1.0/tests/test_examples.py +50 -0
@@ -0,0 +1,52 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ build:
12
+ name: Build distribution
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - name: Check out repository
16
+ uses: actions/checkout@v4
17
+
18
+ - name: Set up Python
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.12"
22
+
23
+ - name: Install build tools
24
+ run: python -m pip install --upgrade build
25
+
26
+ - name: Build distributions
27
+ run: python -m build
28
+
29
+ - name: Store distributions
30
+ uses: actions/upload-artifact@v4
31
+ with:
32
+ name: python-package-distributions
33
+ path: dist/
34
+
35
+ publish:
36
+ name: Publish to PyPI
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment:
40
+ name: pypi
41
+ url: https://pypi.org/p/forkscope
42
+ permissions:
43
+ id-token: write
44
+ steps:
45
+ - name: Download distributions
46
+ uses: actions/download-artifact@v4
47
+ with:
48
+ name: python-package-distributions
49
+ path: dist/
50
+
51
+ - name: Publish distributions
52
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,32 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Check out repository
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+ cache: pip
21
+
22
+ - name: Install package and development tools
23
+ run: python -m pip install -e ".[dev]"
24
+
25
+ - name: Check formatting and lint
26
+ run: ruff check .
27
+
28
+ - name: Run tests
29
+ run: pytest
30
+
31
+ - name: Build wheel
32
+ run: python -m build
@@ -0,0 +1,29 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+
10
+ # Tool caches
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # Local environment and editor files
18
+ .env
19
+ .idea/
20
+ .vscode/
21
+ .DS_Store
22
+
23
+ # Forkscope outputs and local model artifacts
24
+ outputs/
25
+ *.ckpt
26
+ *.pt
27
+ *.pth
28
+ *.safetensors
29
+
@@ -0,0 +1,8 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.6.9
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
8
+
@@ -0,0 +1,2 @@
1
+ 3.12
2
+
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Forkscope contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.5
2
+ Name: forkscope
3
+ Version: 0.1.0
4
+ Summary: A local workbench for evaluating test-time planning rollouts.
5
+ Author: Forkscope contributors
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: numpy>=1.26
10
+ Requires-Dist: pydantic>=2.8
11
+ Requires-Dist: typer>=0.12
12
+ Provides-Extra: dev
13
+ Requires-Dist: build>=1.2; extra == 'dev'
14
+ Requires-Dist: mypy>=1.11; extra == 'dev'
15
+ Requires-Dist: pytest>=8.3; extra == 'dev'
16
+ Requires-Dist: ruff>=0.6; extra == 'dev'
17
+ Provides-Extra: gymnasium
18
+ Requires-Dist: gymnasium>=1.0; extra == 'gymnasium'
19
+ Provides-Extra: jax
20
+ Requires-Dist: jax>=0.4.30; extra == 'jax'
21
+ Provides-Extra: torch
22
+ Requires-Dist: torch>=2.4; extra == 'torch'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # ForkScope
26
+
27
+ a tool for finding the optimum hyperparameters for world model rollouts at inference.
28
+
29
+ works for setups that use test-time, action-conditioned planning rollouts (planner generates a bunch
30
+ of actions, world model scores those actions based on outcome, planner evaluates and selects the best
31
+ one to execute).
32
+
33
+ ## Runnable example
34
+
35
+ The [point-mass example](examples/point_mass/README.md) includes a small environment, a planner,
36
+ zero-argument factories for the CLI, a reference YAML config, and a programmatic example. Install
37
+ ForkScope in editable mode from the repository root with `python -m pip install -e .`, then run:
38
+
39
+ ```sh
40
+ PYTHONPATH=. forkscope run \
41
+ --env-factory examples.point_mass.example:make_environment \
42
+ --planner-factory examples.point_mass.example:make_planner \
43
+ --seeds 0,1,2 \
44
+ --horizons 1,2,4 \
45
+ --max-steps 10 \
46
+ --output outputs/point_mass/results.jsonl
47
+ ```
48
+
49
+ The same factories can be passed to `RolloutBenchmarker` from Python. See the example README for
50
+ that usage and details about the JSON Lines output. The YAML file documents the matching settings;
51
+ the CLI currently accepts options directly and does not load YAML configs.
@@ -0,0 +1,27 @@
1
+ # ForkScope
2
+
3
+ a tool for finding the optimum hyperparameters for world model rollouts at inference.
4
+
5
+ works for setups that use test-time, action-conditioned planning rollouts (planner generates a bunch
6
+ of actions, world model scores those actions based on outcome, planner evaluates and selects the best
7
+ one to execute).
8
+
9
+ ## Runnable example
10
+
11
+ The [point-mass example](examples/point_mass/README.md) includes a small environment, a planner,
12
+ zero-argument factories for the CLI, a reference YAML config, and a programmatic example. Install
13
+ ForkScope in editable mode from the repository root with `python -m pip install -e .`, then run:
14
+
15
+ ```sh
16
+ PYTHONPATH=. forkscope run \
17
+ --env-factory examples.point_mass.example:make_environment \
18
+ --planner-factory examples.point_mass.example:make_planner \
19
+ --seeds 0,1,2 \
20
+ --horizons 1,2,4 \
21
+ --max-steps 10 \
22
+ --output outputs/point_mass/results.jsonl
23
+ ```
24
+
25
+ The same factories can be passed to `RolloutBenchmarker` from Python. See the example README for
26
+ that usage and details about the JSON Lines output. The YAML file documents the matching settings;
27
+ the CLI currently accepts options directly and does not load YAML configs.
@@ -0,0 +1,22 @@
1
+ # Releasing ForkScope
2
+
3
+ ForkScope releases are published to PyPI from GitHub Actions using PyPI trusted publishing. No PyPI API token is stored in GitHub.
4
+
5
+ ## One-time setup
6
+
7
+ 1. Confirm the project name `forkscope` is available on PyPI and create the project with its first trusted publisher. For the first release, PyPI's pending publisher flow can be used before the project exists.
8
+ 2. Configure the trusted publisher with:
9
+ - Owner: `alonso130r`
10
+ - Repository: `forkscope`
11
+ - Workflow: `publish.yml`
12
+ - Environment: `pypi`
13
+ 3. In the GitHub repository, create the `pypi` deployment environment under **Settings > Environments**. Configure any desired approval rules before publishing.
14
+
15
+ ## Each release
16
+
17
+ 1. Update `version` in `pyproject.toml` and commit the change to `main`.
18
+ 2. Create and push a tag that matches that version with a `v` prefix, for example `v0.1.0` for version `0.1.0`.
19
+ 3. Create a GitHub Release from that tag and publish it. The `Publish to PyPI` workflow builds the sdist and wheel, then publishes both to PyPI.
20
+ 4. Confirm the workflow succeeds and the new version appears on the [ForkScope PyPI project page](https://pypi.org/project/forkscope/).
21
+
22
+ PyPI does not allow re-uploading an existing version. If a release workflow fails after a version has already been published, fix the issue and release a new patch version.
@@ -0,0 +1 @@
1
+ """Runnable ForkScope examples."""
@@ -0,0 +1,52 @@
1
+ # Point-mass example
2
+
3
+ This example compares planning horizons on a deterministic one-dimensional task. The environment starts at `-seed`, has a goal at position `5`, and moves by the action selected by the planner. The planner moves toward the goal by at most the current horizon. This makes it easy to see how the same environment and seed behave across horizon settings.
4
+
5
+ Run commands from the repository root after installing ForkScope and its NumPy dependency:
6
+
7
+ ```sh
8
+ python -m pip install -e .
9
+ ```
10
+
11
+ ## CLI
12
+
13
+ The example exposes zero-argument factories for the environment and planner. Set `PYTHONPATH=.` so the CLI can import the example package from the repository root, then pass the settings from [`config.yaml`](config.yaml):
14
+
15
+ ```sh
16
+ PYTHONPATH=. forkscope run \
17
+ --env-factory examples.point_mass.example:make_environment \
18
+ --planner-factory examples.point_mass.example:make_planner \
19
+ --seeds 0,1,2 \
20
+ --horizons 1,2,4 \
21
+ --max-steps 10 \
22
+ --output outputs/point_mass/results.jsonl
23
+ ```
24
+
25
+ The CLI accepts factory paths and individual options; it does not read YAML files directly. `config.yaml` records the same settings for reference and for use in your own launcher or workflow.
26
+
27
+ ## Python API
28
+
29
+ The script runs the same sweep using `RolloutBenchmarker`:
30
+
31
+ ```sh
32
+ PYTHONPATH=. python -m examples.point_mass.example
33
+ ```
34
+
35
+ It writes JSON Lines records to `outputs/point_mass/programmatic-results.jsonl`. You can also import its factories into your own program:
36
+
37
+ ```python
38
+ from examples.point_mass.example import make_environment, make_planner
39
+ from forkscope import RolloutBenchmarker
40
+
41
+ benchmarker = RolloutBenchmarker(
42
+ make_environment,
43
+ make_planner,
44
+ seeds=[0, 1, 2],
45
+ horizons=[1, 2, 4],
46
+ max_steps=10,
47
+ )
48
+ records = benchmarker.run()
49
+ benchmarker.write_results("outputs/point_mass/results.jsonl")
50
+ ```
51
+
52
+ Each output line is one episode record, including its seed, horizon, total reward, termination status, and step-level observations, actions, rewards, and environment info.
@@ -0,0 +1 @@
1
+ """Point-mass horizon-sweep example."""
@@ -0,0 +1,14 @@
1
+ # Reference settings for the CLI and Python API examples.
2
+ # ForkScope's CLI currently takes these as command-line options; it does not
3
+ # load YAML files directly.
4
+ environment:
5
+ factory: examples.point_mass.example:make_environment
6
+
7
+ planner:
8
+ factory: examples.point_mass.example:make_planner
9
+
10
+ evaluation:
11
+ seeds: [0, 1, 2]
12
+ horizons: [1, 2, 4]
13
+ max_steps: 10
14
+ output_path: outputs/point_mass/results.jsonl
@@ -0,0 +1,81 @@
1
+ """Small deterministic environment and planner for a horizon sweep."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+ from forkscope import RolloutBenchmarker
9
+
10
+
11
+ class PointMassEnvironment:
12
+ """One-dimensional point mass with a fixed goal at position 5."""
13
+
14
+ observation_space = None
15
+ action_space = None
16
+
17
+ def __init__(self, goal: float = 5.0) -> None:
18
+ self.goal = goal
19
+ self.position = 0.0
20
+
21
+ def reset(
22
+ self, *, seed: int | None = None, options: dict[str, Any] | None = None
23
+ ) -> tuple[dict[str, np.ndarray], dict[str, Any]]:
24
+ self.position = -float(seed or 0)
25
+ return self._observation(), {"seed": seed}
26
+
27
+ def step(
28
+ self, action: float
29
+ ) -> tuple[dict[str, np.ndarray], float, bool, bool, dict[str, Any]]:
30
+ self.position += float(action)
31
+ distance = abs(self.goal - self.position)
32
+ terminated = distance == 0.0
33
+ return self._observation(), -distance, terminated, False, {"distance": distance}
34
+
35
+ def close(self) -> None:
36
+ """Release resources (none are used by this example)."""
37
+
38
+ def _observation(self) -> dict[str, np.ndarray]:
39
+ return {
40
+ "position": np.array([self.position], dtype=np.float64),
41
+ "goal": np.array([self.goal], dtype=np.float64),
42
+ }
43
+
44
+
45
+ class BoundedGreedyPlanner:
46
+ """Move toward the goal by at most the configured planning horizon."""
47
+
48
+ def reset(self, *, seed: int) -> None:
49
+ self.seed = seed
50
+
51
+ def plan(self, observation: dict[str, np.ndarray], *, horizon: int) -> float:
52
+ distance = float(observation["goal"][0] - observation["position"][0])
53
+ return float(np.clip(distance, -horizon, horizon))
54
+
55
+
56
+ def make_environment() -> PointMassEnvironment:
57
+ """Factory used by the CLI and programmatic benchmarker."""
58
+ return PointMassEnvironment()
59
+
60
+
61
+ def make_planner() -> BoundedGreedyPlanner:
62
+ """Factory used by the CLI and programmatic benchmarker."""
63
+ return BoundedGreedyPlanner()
64
+
65
+
66
+ def main() -> None:
67
+ benchmarker = RolloutBenchmarker(
68
+ make_environment,
69
+ make_planner,
70
+ seeds=[0, 1, 2],
71
+ horizons=[1, 2, 4],
72
+ max_steps=10,
73
+ )
74
+ records = benchmarker.run()
75
+ output = Path("outputs/point_mass/programmatic-results.jsonl")
76
+ benchmarker.write_results(output)
77
+ print(f"Wrote {len(records)} episode records to {output}")
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "forkscope"
7
+ version = "0.1.0"
8
+ description = "A local workbench for evaluating test-time planning rollouts."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Forkscope contributors" }]
14
+ dependencies = [
15
+ "numpy>=1.26",
16
+ "pydantic>=2.8",
17
+ "typer>=0.12",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ torch = ["torch>=2.4"]
22
+ jax = ["jax>=0.4.30"]
23
+ gymnasium = ["gymnasium>=1.0"]
24
+ dev = [
25
+ "build>=1.2",
26
+ "mypy>=1.11",
27
+ "pytest>=8.3",
28
+ "ruff>=0.6",
29
+ ]
30
+
31
+ [project.scripts]
32
+ forkscope = "forkscope.cli:app"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/forkscope"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ target-version = "py310"
40
+ src = ["src", "tests"]
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "UP", "B"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ pythonpath = ["src"]
48
+ addopts = "-ra"
49
+
50
+ [tool.mypy]
51
+ python_version = "3.10"
52
+ strict = true
53
+ mypy_path = "src"
@@ -0,0 +1,7 @@
1
+ """Forkscope package."""
2
+
3
+ from forkscope.benchmarker import RolloutBenchmarker
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["RolloutBenchmarker", "__version__"]
@@ -0,0 +1,57 @@
1
+ """Public programmatic API for running rollout horizon benchmarks."""
2
+
3
+ from collections.abc import Callable, Sequence
4
+ from pathlib import Path
5
+
6
+ from forkscope.evaluation.sweep import run_sweep
7
+ from forkscope.interface.interfaces import InterfaceEnvironment
8
+ from forkscope.planning.planner import Planner
9
+ from forkscope.records.records import EpisodeRecord
10
+ from forkscope.records.writer import write_jsonl
11
+
12
+
13
+ class RolloutBenchmarker:
14
+ """Run matched-seed horizon sweeps and retain their episode records."""
15
+
16
+ def __init__(
17
+ self,
18
+ env_factory: Callable[[], InterfaceEnvironment],
19
+ planner_factory: Callable[[], Planner],
20
+ *,
21
+ seeds: Sequence[int],
22
+ horizons: Sequence[int],
23
+ max_steps: int,
24
+ ) -> None:
25
+ seed_values = tuple(seeds)
26
+ horizon_values = tuple(horizons)
27
+ if not seed_values:
28
+ raise ValueError("seeds must contain at least one value.")
29
+ if any(seed < 0 for seed in seed_values):
30
+ raise ValueError("seeds must be non-negative.")
31
+ if not horizon_values or any(horizon <= 0 for horizon in horizon_values):
32
+ raise ValueError("horizons must contain positive values.")
33
+ if max_steps <= 0:
34
+ raise ValueError("max_steps must be greater than zero.")
35
+
36
+ self.env_factory = env_factory
37
+ self.planner_factory = planner_factory
38
+ self.seeds = seed_values
39
+ self.horizons = horizon_values
40
+ self.max_steps = max_steps
41
+ self.results: list[EpisodeRecord] = []
42
+
43
+ def run(self) -> list[EpisodeRecord]:
44
+ """Execute the sweep, save results on this instance, and return them."""
45
+ self.results = []
46
+ self.results = run_sweep(
47
+ self.env_factory,
48
+ self.planner_factory,
49
+ seeds=self.seeds,
50
+ horizons=self.horizons,
51
+ max_steps=self.max_steps,
52
+ )
53
+ return self.results
54
+
55
+ def write_results(self, path: str | Path) -> None:
56
+ """Write the current episode results as JSON Lines."""
57
+ write_jsonl(self.results, path)
@@ -0,0 +1,70 @@
1
+ """Command-line entry point for Forkscope."""
2
+
3
+ import importlib
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import typer
9
+
10
+ from forkscope import RolloutBenchmarker
11
+
12
+ app = typer.Typer(help="Evaluate test-time planning rollouts on your scenarios.")
13
+ OUTPUT_OPTION = typer.Option(Path("results.jsonl"), help="JSONL output path.")
14
+
15
+
16
+ @app.callback()
17
+ def main() -> None:
18
+ """Run ForkScope commands."""
19
+
20
+
21
+ def _load_factory(spec: str) -> Callable[[], Any]:
22
+ """Load a zero-argument factory from a ``module:attribute`` path."""
23
+ module_name, separator, attribute_name = spec.partition(":")
24
+ if not separator or not module_name or not attribute_name:
25
+ raise typer.BadParameter("Use an import path in the form 'package.module:factory'.")
26
+
27
+ try:
28
+ factory = getattr(importlib.import_module(module_name), attribute_name)
29
+ except (ImportError, AttributeError) as exc:
30
+ raise typer.BadParameter(f"Could not load factory {spec!r}: {exc}") from exc
31
+ if not callable(factory):
32
+ raise typer.BadParameter(f"Configured factory {spec!r} is not callable.")
33
+ return factory
34
+
35
+
36
+ def _parse_ints(value: str, option: str, *, minimum: int) -> list[int]:
37
+ try:
38
+ values = [int(item.strip()) for item in value.split(",")]
39
+ except ValueError as exc:
40
+ raise typer.BadParameter(f"{option} must be comma-separated integers.") from exc
41
+ if not values or any(item < minimum for item in values):
42
+ comparison = "non-negative" if minimum == 0 else "greater than zero"
43
+ raise typer.BadParameter(f"{option} values must be {comparison}.")
44
+ return values
45
+
46
+
47
+ @app.command()
48
+ def run(
49
+ env_factory: str = typer.Option(..., help="Environment factory as package.module:callable."),
50
+ planner_factory: str = typer.Option(..., help="Planner factory as package.module:callable."),
51
+ seeds: str = typer.Option("1,2,3,4,5", help="Comma-separated episode seeds."),
52
+ horizons: str = typer.Option("1,2,4,8,16", help="Comma-separated planning horizons."),
53
+ max_steps: int = typer.Option(500, min=1, help="Maximum environment steps per episode."),
54
+ output: Path = OUTPUT_OPTION,
55
+ ) -> None:
56
+ """Run a matched-seed horizon sweep and write its episode records."""
57
+ benchmarker = RolloutBenchmarker(
58
+ _load_factory(env_factory),
59
+ _load_factory(planner_factory),
60
+ seeds=_parse_ints(seeds, "--seeds", minimum=0),
61
+ horizons=_parse_ints(horizons, "--horizons", minimum=1),
62
+ max_steps=max_steps,
63
+ )
64
+ benchmarker.run()
65
+ benchmarker.write_results(output)
66
+ typer.echo(f"Wrote {len(benchmarker.results)} episode records to {output}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ app()
@@ -0,0 +1 @@
1
+ """Episode and sweep evaluation helpers."""
@@ -0,0 +1,57 @@
1
+ from forkscope.interface.interfaces import InterfaceEnvironment
2
+ from forkscope.planning.planner import Planner
3
+ from forkscope.records.records import EpisodeRecord, StepRecord
4
+
5
+
6
+ def run_episode(
7
+ env: InterfaceEnvironment,
8
+ planner: Planner,
9
+ *,
10
+ seed: int,
11
+ horizon: int,
12
+ max_steps: int,
13
+ ) -> EpisodeRecord:
14
+ if max_steps <= 0:
15
+ raise ValueError("max_steps must be greater than zero.")
16
+ if horizon <= 0:
17
+ raise ValueError("horizon must be greater than zero.")
18
+
19
+ planner.reset(seed=seed)
20
+ observation, _ = env.reset(seed=seed)
21
+ steps: list[StepRecord] = []
22
+ total_reward = 0.0
23
+ terminated = truncated = False
24
+
25
+ for step_index in range(max_steps):
26
+ action = planner.plan(
27
+ observation,
28
+ horizon=horizon,
29
+ )
30
+ next_observation, reward, terminated, truncated, info = env.step(action)
31
+ steps.append(
32
+ StepRecord(
33
+ index=step_index,
34
+ observation=observation,
35
+ action=action,
36
+ reward=reward,
37
+ terminated=terminated,
38
+ truncated=truncated,
39
+ info=info,
40
+ )
41
+ )
42
+ total_reward += reward
43
+ observation = next_observation
44
+
45
+ if terminated or truncated:
46
+ break
47
+
48
+ step_limit_reached = not terminated and not truncated and len(steps) == max_steps
49
+ return EpisodeRecord(
50
+ seed=seed,
51
+ horizon=horizon,
52
+ total_reward=total_reward,
53
+ steps=steps,
54
+ terminated=terminated,
55
+ truncated=truncated,
56
+ step_limit_reached=step_limit_reached,
57
+ )