feat(envs): bundled calculator/retrieval-qa/guess-number rollout envs (v0.71.30)

This commit is contained in:
Alpamys 2026-07-05 16:58:52 +05:00
parent 28e306731f
commit 56db9f79ab
5 changed files with 220 additions and 0 deletions

View File

@ -0,0 +1,18 @@
"""Bundled toy rollout environments — v0.71.30.
Each module exposes a ``rollout(prompts) -> list[{"prompt", "answer"}]`` entry
point usable as ``soup_cli.envs.<name>:rollout`` in ``training.rollout_func``
(with ``training.rollout_backend='openenv'``). They let the live openenv GRPO
rollout path (#125) run out-of-the-box against a deterministic curriculum.
Honesty: these are deterministic single-shot **seeders**, NOT interactive
multi-turn model-in-the-loop episodes the live openenv contract calls
``fn(seed_prompts)`` and does not pass the model/tokenizer. Each env produces a
fixed, seeded set of ``{prompt, answer}`` rows scored by the existing
``accuracy`` / ``math`` reward. The seed prompts are accepted (contract shape)
but their content does not change the generated curriculum.
"""
from soup_cli.envs import calculator, guess_number, retrieval_qa
__all__ = ["calculator", "guess_number", "retrieval_qa"]

View File

@ -0,0 +1,42 @@
"""Calculator tool-use rollout env — v0.71.30.
Generates a deterministic set of single-operation arithmetic problems as GRPO
prompt+answer rows. Score with ``reward_fn='math'`` (or ``'accuracy'``).
Usage: ``training.rollout_backend='openenv'`` +
``training.rollout_func='soup_cli.envs.calculator:rollout'``.
"""
from __future__ import annotations
import random
from typing import Any
_SEED = 20730
_DEFAULT_ROWS = 64
_OPS = ("+", "-", "*")
def rollout(prompts: Any = None) -> list[dict]:
"""Return a deterministic list of ``{"prompt", "answer"}`` arithmetic rows.
``prompts`` (the seed prompts from the GRPO dataset) is accepted for the
openenv contract but does not change the generated curriculum the env is
a self-contained deterministic seeder.
"""
rng = random.Random(_SEED)
rows: list[dict] = []
for _ in range(_DEFAULT_ROWS):
op = _OPS[rng.randrange(len(_OPS))]
if op == "*":
a = rng.randint(2, 12)
b = rng.randint(2, 12)
else:
a = rng.randint(0, 99)
b = rng.randint(0, 99)
result = {"+": a + b, "-": a - b, "*": a * b}[op]
prompt = (
f"What is {a} {op} {b}? Reply with just the number."
)
rows.append({"prompt": prompt, "answer": str(result)})
return rows

View File

@ -0,0 +1,43 @@
"""Number-deduction rollout env — v0.71.30.
Generates deterministic single-shot number-deduction puzzles (constraints that
pin a unique integer) as GRPO prompt+answer rows. Score with
``reward_fn='math'`` (or ``'accuracy'``).
Honesty: this is single-shot *deduction*, not interactive guess-and-feedback
the live openenv contract does not pass the model, so a true guessing loop is
out of scope (a follow-up once openenv gains a model-in-the-loop hook).
Usage: ``training.rollout_backend='openenv'`` +
``training.rollout_func='soup_cli.envs.guess_number:rollout'``.
"""
from __future__ import annotations
import random
from typing import Any
_SEED = 20731
_DEFAULT_ROWS = 64
def rollout(prompts: Any = None) -> list[dict]:
"""Return a deterministic list of ``{"prompt", "answer"}`` deduction rows.
Each puzzle states two factors whose product is the answer, plus a range,
so the answer is uniquely deducible. ``prompts`` is accepted for the
openenv contract but does not change the curriculum.
"""
rng = random.Random(_SEED)
rows: list[dict] = []
for _ in range(_DEFAULT_ROWS):
a = rng.randint(2, 9)
b = rng.randint(2, 9)
answer = a * b
prompt = (
f"I'm thinking of a number between 1 and 100. "
f"It equals {a} times {b}. What is the number? "
"Reply with just the number."
)
rows.append({"prompt": prompt, "answer": str(answer)})
return rows

View File

@ -0,0 +1,54 @@
"""Retrieval-QA rollout env — v0.71.30.
Generates deterministic short-document + question rows whose answer is a span
present in the document, as GRPO prompt+answer rows. Score with
``reward_fn='accuracy'`` (the answer span appears in the model's completion).
Usage: ``training.rollout_backend='openenv'`` +
``training.rollout_func='soup_cli.envs.retrieval_qa:rollout'``.
"""
from __future__ import annotations
import random
from typing import Any
_SEED = 20732
_DEFAULT_ROWS = 64
# (entity, attribute, value) fact templates — the answer is always ``value``,
# which is embedded verbatim in the document so it is a retrievable span.
_FACTS = (
("The Zephyr rover", "landed in the year", "2031"),
("The city of Aldermere", "has a population of", "48000"),
("The Blue Comet", "orbits every", "76 years"),
("The Marlow Bridge", "spans", "1200 metres"),
("The Quill library", "holds", "90000 books"),
("Mount Calder", "rises to", "3400 metres"),
("The Aster festival", "lasts", "9 days"),
("The Nadir mine", "reaches a depth of", "800 metres"),
)
def rollout(prompts: Any = None) -> list[dict]:
"""Return a deterministic list of ``{"prompt", "answer"}`` retrieval rows.
Each row embeds a short document containing several facts and asks about
one of them; the answer is a span present in the document. ``prompts`` is
accepted for the openenv contract but does not change the curriculum.
"""
rng = random.Random(_SEED)
rows: list[dict] = []
for _ in range(_DEFAULT_ROWS):
# Pick 3 distinct facts as the document; ask about one of them.
facts = rng.sample(_FACTS, 3)
target = facts[rng.randrange(len(facts))]
entity, attribute, value = target
doc = " ".join(f"{e} {a} {v}." for (e, a, v) in facts)
prompt = (
f"Document: {doc}\n"
f"Question: What does {entity} {attribute.rstrip()}? "
"Reply with just the value."
)
rows.append({"prompt": prompt, "answer": value})
return rows

View File

@ -349,6 +349,69 @@ class TestBuildPrmRewardFn:
mod.build_prm_reward_fn(_T(), device="cpu", trust_remote_code=False)
# ---------------------------------------------------------------------------
# Task 5 — bundled rollout envs
# ---------------------------------------------------------------------------
_ENV_MODULES = ["calculator", "retrieval_qa", "guess_number"]
class TestEnvs:
@pytest.mark.parametrize("modname", _ENV_MODULES)
def test_rows_normalise(self, modname):
import importlib
from soup_cli.utils.agent_rollout import _normalise_rollout_rows
mod = importlib.import_module(f"soup_cli.envs.{modname}")
rows = mod.rollout([])
assert rows, "env must produce a non-empty row set"
norm = _normalise_rollout_rows(rows, "openenv")
assert len(norm) == len(rows)
for row in rows:
assert isinstance(row["prompt"], str) and row["prompt"]
assert isinstance(row["answer"], str) and row["answer"]
@pytest.mark.parametrize("modname", _ENV_MODULES)
def test_deterministic(self, modname):
import importlib
mod = importlib.import_module(f"soup_cli.envs.{modname}")
assert mod.rollout([]) == mod.rollout([])
@pytest.mark.parametrize("modname", _ENV_MODULES)
def test_rollout_signature_ignores_prompt_content(self, modname):
import importlib
mod = importlib.import_module(f"soup_cli.envs.{modname}")
# Passing seed prompts must not crash and stays deterministic.
assert mod.rollout(["seed a", "seed b"]) == mod.rollout(["x", "y"])
def test_calculator_answers_correct(self):
import re
from soup_cli.envs.calculator import rollout
for row in rollout([]):
m = re.search(r"(-?\d+)\s*([+\-*])\s*(-?\d+)", row["prompt"])
assert m is not None, row["prompt"]
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
expected = {"+": a + b, "-": a - b, "*": a * b}[op]
assert row["answer"] == str(expected)
def test_guess_number_answer_is_int(self):
from soup_cli.envs.guess_number import rollout
for row in rollout([]):
assert row["answer"].lstrip("-").isdigit()
def test_retrieval_qa_answer_in_prompt(self):
from soup_cli.envs.retrieval_qa import rollout
for row in rollout([]):
# The answer span must appear in the document/prompt.
assert row["answer"] in row["prompt"]
# ---------------------------------------------------------------------------
# Task 4 — GRPO wiring
# ---------------------------------------------------------------------------