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.
- forkscope-0.1.0/.github/workflows/publish.yml +52 -0
- forkscope-0.1.0/.github/workflows/test.yml +32 -0
- forkscope-0.1.0/.gitignore +29 -0
- forkscope-0.1.0/.pre-commit-config.yaml +8 -0
- forkscope-0.1.0/.python-version +2 -0
- forkscope-0.1.0/LICENSE +22 -0
- forkscope-0.1.0/PKG-INFO +51 -0
- forkscope-0.1.0/README.md +27 -0
- forkscope-0.1.0/RELEASING.md +22 -0
- forkscope-0.1.0/examples/__init__.py +1 -0
- forkscope-0.1.0/examples/point_mass/README.md +52 -0
- forkscope-0.1.0/examples/point_mass/__init__.py +1 -0
- forkscope-0.1.0/examples/point_mass/config.yaml +14 -0
- forkscope-0.1.0/examples/point_mass/example.py +81 -0
- forkscope-0.1.0/pyproject.toml +53 -0
- forkscope-0.1.0/src/forkscope/__init__.py +7 -0
- forkscope-0.1.0/src/forkscope/benchmarker.py +57 -0
- forkscope-0.1.0/src/forkscope/cli.py +70 -0
- forkscope-0.1.0/src/forkscope/evaluation/__init__.py +1 -0
- forkscope-0.1.0/src/forkscope/evaluation/episode.py +57 -0
- forkscope-0.1.0/src/forkscope/evaluation/sweep.py +37 -0
- forkscope-0.1.0/src/forkscope/interface/__init__.py +1 -0
- forkscope-0.1.0/src/forkscope/interface/interfaces.py +27 -0
- forkscope-0.1.0/src/forkscope/interface/tree.py +252 -0
- forkscope-0.1.0/src/forkscope/planning/__init__.py +1 -0
- forkscope-0.1.0/src/forkscope/planning/planner.py +16 -0
- forkscope-0.1.0/src/forkscope/records/__init__.py +1 -0
- forkscope-0.1.0/src/forkscope/records/records.py +28 -0
- forkscope-0.1.0/src/forkscope/records/writer.py +30 -0
- forkscope-0.1.0/tests/.gitkeep +1 -0
- forkscope-0.1.0/tests/__init__.py +1 -0
- forkscope-0.1.0/tests/examples.py +53 -0
- forkscope-0.1.0/tests/helpers.py +56 -0
- forkscope-0.1.0/tests/test_benchmarker.py +212 -0
- forkscope-0.1.0/tests/test_cli.py +51 -0
- 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
|
+
|
forkscope-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
|
forkscope-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|
+
)
|