Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions .github/workflows/pr-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
name: PR Test

on:
# Do not run CI on push to reduce CI time
# push:
# branches: [main]
# Push to main triggers ONLY the cpu jobs (cheap GitHub-hosted runner) — see
# the per-job `if:` below, which adds `push` only for cpu configs. GPU jobs
# stay PR/label-gated so push events never burn the self-hosted fleet. This
# catches PR-pair regressions where two PRs pass individually but main breaks
# after both land.
push:
branches: [main]
pull_request:
branches: [main]
types: [synchronize, labeled]
Expand Down Expand Up @@ -468,15 +472,17 @@ jobs:
e2e-test-plugin-contracts:
needs: pre-commit

if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'

if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'



runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
info: [{"num_gpus": 0, "test_file": "test_megatron_argument_validation.py"}, {"num_gpus": 0, "test_file": "plugin_contracts/test_plugin_rollout_contracts.py"}, {"num_gpus": 0, "test_file": "plugin_contracts/test_plugin_runtime_hook_contracts.py"}, {"num_gpus": 0, "test_file": "plugin_contracts/test_plugin_path_loading_contracts.py"}, {"num_gpus": 0, "test_file": "plugin_contracts/test_plugin_generate_contracts.py"}]
info: [{"num_gpus": 0, "test_file": "test_megatron_argument_validation.py"}, {"num_gpus": 0, "test_file": "plugin_contracts/test_plugin_rollout_contracts.py"}, {"num_gpus": 0, "test_file": "plugin_contracts/test_plugin_runtime_hook_contracts.py"}, {"num_gpus": 0, "test_file": "plugin_contracts/test_plugin_path_loading_contracts.py"}, {"num_gpus": 0, "test_file": "plugin_contracts/test_plugin_generate_contracts.py"}, {"num_gpus": 0, "test_file": "test_rm_deepscaler.py"}, {"num_gpus": 0, "test_file": "test_rm_f1.py"}, {"num_gpus": 0, "test_file": "test_rm_gpqa.py"}, {"num_gpus": 0, "test_file": "test_rm_math.py"}, {"num_gpus": 0, "test_file": "test_rm_math_dapo.py"}]
defaults:
run:
working-directory: ${{ github.workspace }}
Expand Down Expand Up @@ -767,4 +773,4 @@ jobs:
else
python tests/ci/gpu_lock_exec.py --count "$NUM_GPUS" -- python "$TEST_PATH" "${TEST_ARGS_ARRAY[@]}"
fi
'
'
19 changes: 16 additions & 3 deletions .github/workflows/pr-test.yml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@
{'test_file': 'plugin_contracts/test_plugin_runtime_hook_contracts.py', 'num_gpus': 0},
{'test_file': 'plugin_contracts/test_plugin_path_loading_contracts.py', 'num_gpus': 0},
{'test_file': 'plugin_contracts/test_plugin_generate_contracts.py', 'num_gpus': 0},
{'test_file': 'test_rm_deepscaler.py', 'num_gpus': 0},
{'test_file': 'test_rm_f1.py', 'num_gpus': 0},
{'test_file': 'test_rm_gpqa.py', 'num_gpus': 0},
{'test_file': 'test_rm_math.py', 'num_gpus': 0},
{'test_file': 'test_rm_math_dapo.py', 'num_gpus': 0},
],
},

Expand All @@ -85,9 +90,13 @@
name: PR Test

on:
# Do not run CI on push to reduce CI time
# push:
# branches: [main]
# Push to main triggers ONLY the cpu jobs (cheap GitHub-hosted runner) — see
# the per-job `if:` below, which adds `push` only for cpu configs. GPU jobs
# stay PR/label-gated so push events never burn the self-hosted fleet. This
# catches PR-pair regressions where two PRs pass individually but main breaks
# after both land.
push:
branches: [main]
pull_request:
branches: [main]
types: [synchronize, labeled]
Expand Down Expand Up @@ -128,7 +137,11 @@ jobs:
<< job_name >>:
needs: pre-commit
<% if config.get('always') %>
<% if config.get('cpu') %>
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
<% else %>
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
<% endif %>
<% else %>
if: (github.event_name == 'workflow_dispatch') || (github.event.pull_request && contains(github.event.pull_request.labels.*.name, '<< config.label >>'))
<% endif %>
Expand Down
96 changes: 96 additions & 0 deletions tests/test_rm_deepscaler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""CPU unit tests for ``vime.rollout.rm_hub.deepscaler``.

Pins the wrapper that decides which segment of the response counts as
the "solution" and reduces grading to ``math_utils``. The branching is
small (3 cases) but silent-failure prone: if the ``</think>`` /
``###Response`` markers stop matching the format the rollout actually
produces, the function returns 0 *for every sample* and no other CI
signal would catch it.
"""

from __future__ import annotations

import pytest

from vime.rollout.rm_hub.deepscaler import get_deepscaler_rule_based_reward


@pytest.mark.unit
def test_response_split_on_think_marker_grades_tail():
"""The default chat format puts the answer after ``</think>``. Only
the tail is graded — pre-think reasoning is ignored, even if it
contains the wrong answer first."""
response = r"Let me reconsider. \boxed{99}</think>Final: \boxed{42}"
assert get_deepscaler_rule_based_reward(response, "42") == 1


@pytest.mark.unit
def test_response_split_on_response_marker_grades_tail():
"""Alternate format: ``###Response`` separator. Only what comes after
is graded (deepscaler.py:7-8)."""
response = r"Scratch work \boxed{wrong}###Response\boxed{42}"
assert get_deepscaler_rule_based_reward(response, "42") == 1


@pytest.mark.unit
def test_response_without_any_marker_returns_zero():
"""No ``</think>`` AND no ``###Response`` → fall through to 0
immediately (deepscaler.py:9-10). This is the silent-failure pole —
if upstream chat templates drop both markers, all rewards become 0."""
assert get_deepscaler_rule_based_reward(r"\boxed{42}", "42") == 0


@pytest.mark.unit
def test_response_with_no_boxed_answer_returns_zero():
"""Marker is present but no ``\\boxed`` in the tail → ``extract_answer``
returns None → 0 (deepscaler.py:13-14)."""
assert get_deepscaler_rule_based_reward("plain</think>no box here", "42") == 0


@pytest.mark.unit
def test_empty_label_returns_zero():
"""Empty ground-truth → 0 (deepscaler.py:15-16). Guards against
missing-label data poisoning training with spurious 0s — explicitly
the same as wrong-answer, intentional."""
assert get_deepscaler_rule_based_reward(r"</think>\boxed{42}", "") == 0


@pytest.mark.unit
def test_label_as_int_is_coerced_to_string():
"""Integer labels are accepted and ``str()``'d (deepscaler.py:19, 25).
Common case for datasets that store numeric labels."""
assert get_deepscaler_rule_based_reward(r"</think>\boxed{42}", 42) == 1


@pytest.mark.unit
def test_label_as_float_is_coerced_to_string():
"""float labels: stringified to e.g. "42.0". The current grader path
(mathd or sympy) handles "42.0" vs "42" via normalization — pinning
the wiring, not the equality logic."""
assert get_deepscaler_rule_based_reward(r"</think>\boxed{42}", 42) == 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The test is intended to verify float labels, but it passes the integer 42 instead of the float 42.0. This makes it identical to test_label_as_int_is_coerced_to_string and fails to actually test float coercion.

Suggested change
assert get_deepscaler_rule_based_reward(r"</think>\boxed{42}", 42) == 1
assert get_deepscaler_rule_based_reward(r"</think>\boxed{42}", 42.0) == 1



@pytest.mark.unit
def test_label_with_boxed_marker_is_extracted_too():
"""If the ground truth itself is wrapped in ``\\boxed{}``, it must be
unwrapped before grading (deepscaler.py:26-29)."""
assert get_deepscaler_rule_based_reward(r"</think>\boxed{42}", r"\boxed{42}") == 1


@pytest.mark.unit
def test_wrong_answer_returns_zero():
"""Sanity-check the negative side of the contract."""
assert get_deepscaler_rule_based_reward(r"</think>\boxed{43}", "42") == 0


@pytest.mark.unit
def test_grader_uses_either_mathd_or_sympy_path():
"""``\\frac{1}{2}`` vs ``0.5`` — mathd_normalize collapses both, even
though the strings aren't lexically equal. Pins the "either mathd OR
sympy succeeds" disjunction at deepscaler.py:38."""
response = r"</think>\boxed{\frac{1}{2}}"
assert get_deepscaler_rule_based_reward(response, "0.5") == 1


if __name__ == "__main__":
raise SystemExit(pytest.main([__file__]))
102 changes: 102 additions & 0 deletions tests/test_rm_f1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""CPU unit tests for ``vime.rollout.rm_hub.f1``.

Pins the token-F1 contract used by the ``f1`` rm_type. The whole pipeline
is pure Python (regex + ``collections.Counter``), so any silent drift
here directly distorts a training run's reward signal without touching a
crash log. Cover the four shapes a reward consumer cares about:

- normalize_answer: article-strip + punctuation-strip + lowercase +
whitespace-collapse (the order matters — ``a.`` should normalize to
`""`, not `"a"`)
- yes/no/noanswer special-case (exact-match required, not token F1)
- zero-overlap path (returns the ZERO_METRIC sentinel)
- non-trivial F1 with hand-derived precision/recall

The module ships zero existing tests; if any of the regexes or the
Counter intersection break, no other CI signal would notice.
"""

from __future__ import annotations

import pytest

from vime.rollout.rm_hub.f1 import f1_score, normalize_answer


@pytest.mark.unit
@pytest.mark.parametrize(
"raw,expected",
[
("The Quick Brown Fox.", "quick brown fox"), # articles stripped, punc removed, lowercased
("An apple, a day", "apple day"), # both "an" and "a" stripped
("HELLO WORLD", "hello world"), # whitespace collapsed
("a.b,c!", "abc"), # punc adjacent to chars collapses without leaving a space
("", ""), # empty input survives
("the", ""), # all-article input collapses to empty
],
)
def test_normalize_answer(raw, expected):
assert normalize_answer(raw) == expected


@pytest.mark.unit
def test_f1_exact_match_is_perfect():
"""Hand-derived: tokens fully overlap → precision=recall=f1=1.0."""
f1, p, r = f1_score("Paris is the capital", "Paris is the capital")
assert (f1, p, r) == (1.0, 1.0, 1.0)


@pytest.mark.unit
def test_f1_partial_overlap_hand_derived():
"""Hand-derived: prediction "the brown fox" → ["brown", "fox"] after
normalize; ground truth "a quick brown fox" → ["quick", "brown", "fox"].
Intersection = {"brown", "fox"} → num_same = 2.
precision = 2 / 2 = 1.0 (len(pred_tokens) = 2)
recall = 2 / 3
f1 = 2 * 1.0 * (2/3) / (1.0 + 2/3) = 0.8
"""
f1, p, r = f1_score("the brown fox", "a quick brown fox")
assert p == pytest.approx(1.0)
assert r == pytest.approx(2 / 3)
assert f1 == pytest.approx(0.8)


@pytest.mark.unit
def test_f1_no_token_overlap_returns_zero_metric():
"""Disjoint vocabularies → ZERO_METRIC sentinel (0, 0, 0)."""
assert f1_score("apple banana", "carrot date") == (0, 0, 0)


@pytest.mark.unit
def test_f1_none_prediction_returns_zero_metric():
"""A failed/missing prediction is a common rm path — must be zero, not raise."""
assert f1_score(None, "anything") == (0, 0, 0)


@pytest.mark.unit
@pytest.mark.parametrize("special", ["yes", "no", "noanswer"])
def test_f1_special_token_pred_mismatch_returns_zero(special):
"""yes/no/noanswer in the prediction but not the ground truth — must be
zero even if token-F1 would otherwise be non-zero. Pins the asymmetric
early-exit at f1.py:33."""
assert f1_score(special, "some other phrase") == (0, 0, 0)


@pytest.mark.unit
@pytest.mark.parametrize("special", ["yes", "no", "noanswer"])
def test_f1_special_token_gt_mismatch_returns_zero(special):
"""Mirror check: special tokens in the ground truth (f1.py:35)."""
assert f1_score("some other phrase", special) == (0, 0, 0)


@pytest.mark.unit
def test_f1_special_token_exact_match_uses_token_path():
"""When prediction == ground_truth == "yes", the special-case early-exit
does NOT fire (it has ``!=`` guards), so we land on the token-F1 path
with a single common token → f1 = 1.0."""
f1, p, r = f1_score("yes", "yes")
assert (f1, p, r) == (1.0, 1.0, 1.0)


if __name__ == "__main__":
raise SystemExit(pytest.main([__file__]))
Loading
Loading