From 56db9f79ab3e38538110cd09dc812f529207cd12 Mon Sep 17 00:00:00 2001 From: Alpamys Date: Sun, 5 Jul 2026 16:58:52 +0500 Subject: [PATCH] feat(envs): bundled calculator/retrieval-qa/guess-number rollout envs (v0.71.30) --- src/soup_cli/envs/__init__.py | 18 +++++++++ src/soup_cli/envs/calculator.py | 42 +++++++++++++++++++++ src/soup_cli/envs/guess_number.py | 43 +++++++++++++++++++++ src/soup_cli/envs/retrieval_qa.py | 54 ++++++++++++++++++++++++++ tests/test_v07130.py | 63 +++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+) create mode 100644 src/soup_cli/envs/__init__.py create mode 100644 src/soup_cli/envs/calculator.py create mode 100644 src/soup_cli/envs/guess_number.py create mode 100644 src/soup_cli/envs/retrieval_qa.py diff --git a/src/soup_cli/envs/__init__.py b/src/soup_cli/envs/__init__.py new file mode 100644 index 0000000..8f27c17 --- /dev/null +++ b/src/soup_cli/envs/__init__.py @@ -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.: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"] diff --git a/src/soup_cli/envs/calculator.py b/src/soup_cli/envs/calculator.py new file mode 100644 index 0000000..43688fa --- /dev/null +++ b/src/soup_cli/envs/calculator.py @@ -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 diff --git a/src/soup_cli/envs/guess_number.py b/src/soup_cli/envs/guess_number.py new file mode 100644 index 0000000..165565e --- /dev/null +++ b/src/soup_cli/envs/guess_number.py @@ -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 diff --git a/src/soup_cli/envs/retrieval_qa.py b/src/soup_cli/envs/retrieval_qa.py new file mode 100644 index 0000000..75d35fa --- /dev/null +++ b/src/soup_cli/envs/retrieval_qa.py @@ -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 diff --git a/tests/test_v07130.py b/tests/test_v07130.py index 6ad3c38..9c22f35 100644 --- a/tests/test_v07130.py +++ b/tests/test_v07130.py @@ -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 # ---------------------------------------------------------------------------