23f2002275 commited on
Commit ·
fa599d5
1
Parent(s): 9e58a11
fix(reward): align prompt with SFT, soft format, real recursion signal (A.3 + A.4-bis)
Browse files- REWARD_AUDIT.md +47 -16
- configs/reward/v1.yaml +4 -4
- rewards/compose.py +68 -41
- rewards/recursion_efficiency.py +15 -11
- rewards/recursion_extract.py +66 -0
- scripts/verify_recursion_reward.py +66 -0
- tests/test_recursion_extract.py +55 -0
- tests/test_rewards.py +96 -29
- train/grpo.py +28 -21
REWARD_AUDIT.md
CHANGED
|
@@ -16,11 +16,11 @@ format_gate=1.0 still gives partial credit via token_budget + recursion_efficien
|
|
| 16 |
score = compose_reward_single("<answer></answer>", "Rome", 100, 0, cfg_reward)
|
| 17 |
```
|
| 18 |
|
| 19 |
-
**Expected:** ≤ 0.
|
| 20 |
|
| 21 |
-
**Result:** 0.
|
| 22 |
|
| 23 |
-
**Status:** MITIGATED — A-02 correctness==0 cap blocks this. Empty answers have correctness=0.
|
| 24 |
|
| 25 |
---
|
| 26 |
|
|
@@ -72,20 +72,20 @@ or finds a copy of the gold answer in the context itself (DATA-04 post-check).
|
|
| 72 |
|
| 73 |
---
|
| 74 |
|
| 75 |
-
## A-05: Recursion depth gaming (
|
| 76 |
|
| 77 |
-
**Vector:** Model uses 0 llm() calls
|
| 78 |
-
recursion_efficiency
|
|
|
|
| 79 |
|
| 80 |
-
**Analysis:**
|
| 81 |
-
- recursion_efficiency
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
|
| 87 |
-
**Status:**
|
| 88 |
-
The model cannot exploit efficiency to score high without being correct.
|
| 89 |
|
| 90 |
---
|
| 91 |
|
|
@@ -108,15 +108,46 @@ normalization. Substring matches are rejected.
|
|
| 108 |
|
| 109 |
---
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
## Summary
|
| 112 |
|
| 113 |
| Attempt | Vector | Status | Mitigation |
|
| 114 |
|---------|--------|--------|------------|
|
| 115 |
-
| A-01 | Format-only (empty answer) | ✅ MITIGATED | A-02 correctness==0 cap (≤0.
|
| 116 |
| A-02 | Case/punctuation gaming | ✅ ACCEPTABLE | Intentional normalizer behavior |
|
| 117 |
| A-03 | Context verbatim copy | ✅ MITIGATED | Generator `_assert_no_leak()` |
|
| 118 |
| A-04 | Length reward exploit | ✅ MITIGATED | capped_linear token_budget |
|
| 119 |
-
| A-05 | Recursion avoidance | ✅
|
| 120 |
| A-06 | Partial answer substring | ✅ MITIGATED | Exact-match normalizer |
|
|
|
|
|
|
|
| 121 |
|
| 122 |
**VERDICT: REWARD SYSTEM APPROVED FOR PHASE 2 TRAINING**
|
|
|
|
| 16 |
score = compose_reward_single("<answer></answer>", "Rome", 100, 0, cfg_reward)
|
| 17 |
```
|
| 18 |
|
| 19 |
+
**Expected:** ≤ 0.15 (A-02 cap is 0.05, plus 0.10 soft format bonus)
|
| 20 |
|
| 21 |
+
**Result:** 0.15 (matches cap) ✅
|
| 22 |
|
| 23 |
+
**Status:** MITIGATED — A-02 correctness==0 cap blocks this. Empty answers have correctness=0, so even with the format bonus, the maximum is 0.15.
|
| 24 |
|
| 25 |
---
|
| 26 |
|
|
|
|
| 72 |
|
| 73 |
---
|
| 74 |
|
| 75 |
+
## A-05: Recursion depth gaming (REVISED for v3)
|
| 76 |
|
| 77 |
+
**Vector:** Model uses 0 llm() calls on every task to maximize
|
| 78 |
+
recursion_efficiency, even on multi_needle / 200K tasks where recursion
|
| 79 |
+
would actually help correctness.
|
| 80 |
|
| 81 |
+
**Analysis (v3):**
|
| 82 |
+
- recursion_efficiency contributes only when correctness == 1.0 (gating
|
| 83 |
+
in compose.py). On hard tasks where 0 calls fails to produce a correct
|
| 84 |
+
answer, the efficiency bonus is forfeited entirely.
|
| 85 |
+
- Net incentive: use the *minimum* recursion that still produces a
|
| 86 |
+
correct answer. Exactly the desired behavior.
|
| 87 |
|
| 88 |
+
**Status:** ✅ MITIGATED by correctness-gating.
|
|
|
|
| 89 |
|
| 90 |
---
|
| 91 |
|
|
|
|
| 108 |
|
| 109 |
---
|
| 110 |
|
| 111 |
+
## A-07: Comment-spam exploit (NEW)
|
| 112 |
+
|
| 113 |
+
**Vector:** Model emits `# llm(foo)` inside code blocks to inflate the
|
| 114 |
+
count regex without making real calls. (Inverted variant of A-05: spam
|
| 115 |
+
to make recursion_eff *lower*, useless because lower efficiency hurts.)
|
| 116 |
+
|
| 117 |
+
**Test:** `test_call_in_comment_not_counted` in
|
| 118 |
+
`tests/test_recursion_extract.py`.
|
| 119 |
+
|
| 120 |
+
**Result:** 0 calls counted ✅ — extractor uses tokenize, ignores comments.
|
| 121 |
+
|
| 122 |
+
**Status:** ✅ MITIGATED by tokenize-aware extraction.
|
| 123 |
+
|
| 124 |
+
---
|
| 125 |
+
|
| 126 |
+
## A-08: String-literal exploit (NEW)
|
| 127 |
+
|
| 128 |
+
**Vector:** Model writes `"earlier code did llm(...)"` in a string
|
| 129 |
+
literal to confuse a naive regex extractor.
|
| 130 |
+
|
| 131 |
+
**Test:** `test_call_in_string_literal_not_counted`.
|
| 132 |
+
|
| 133 |
+
**Result:** 0 calls counted ✅ — tokenize correctly identifies STRING
|
| 134 |
+
tokens and skips them.
|
| 135 |
+
|
| 136 |
+
**Status:** ✅ MITIGATED.
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
## Summary
|
| 141 |
|
| 142 |
| Attempt | Vector | Status | Mitigation |
|
| 143 |
|---------|--------|--------|------------|
|
| 144 |
+
| A-01 | Format-only (empty answer) | ✅ MITIGATED | A-02 correctness==0 cap (≤0.15 with bonus) |
|
| 145 |
| A-02 | Case/punctuation gaming | ✅ ACCEPTABLE | Intentional normalizer behavior |
|
| 146 |
| A-03 | Context verbatim copy | ✅ MITIGATED | Generator `_assert_no_leak()` |
|
| 147 |
| A-04 | Length reward exploit | ✅ MITIGATED | capped_linear token_budget |
|
| 148 |
+
| A-05 | Recursion avoidance | ✅ MITIGATED | Correctness-gating |
|
| 149 |
| A-06 | Partial answer substring | ✅ MITIGATED | Exact-match normalizer |
|
| 150 |
+
| A-07 | Comment-spam exploit | ✅ MITIGATED | Tokenize-aware extraction |
|
| 151 |
+
| A-08 | String-literal exploit | ✅ MITIGATED | Tokenize-aware extraction |
|
| 152 |
|
| 153 |
**VERDICT: REWARD SYSTEM APPROVED FOR PHASE 2 TRAINING**
|
configs/reward/v1.yaml
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
alpha: 0.2
|
| 2 |
weights:
|
| 3 |
-
correctness: 0.
|
| 4 |
-
token_budget: 0.
|
| 5 |
-
recursion_efficiency: 0.
|
| 6 |
token_budget_variant: "capped_linear"
|
| 7 |
answer_regex: "<answer>(.*?)</answer>"
|
| 8 |
-
max_calls:
|
|
|
|
| 1 |
alpha: 0.2
|
| 2 |
weights:
|
| 3 |
+
correctness: 0.70
|
| 4 |
+
token_budget: 0.15
|
| 5 |
+
recursion_efficiency: 0.15
|
| 6 |
token_budget_variant: "capped_linear"
|
| 7 |
answer_regex: "<answer>(.*?)</answer>"
|
| 8 |
+
max_calls: 4
|
rewards/compose.py
CHANGED
|
@@ -1,10 +1,21 @@
|
|
| 1 |
-
"""Reward composition — REW-02.
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
@@ -14,76 +25,92 @@ from .format_gate import format_gate
|
|
| 14 |
from .correctness import correctness
|
| 15 |
from .token_budget import token_budget
|
| 16 |
from .recursion_efficiency import recursion_efficiency
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def compose_reward_single(
|
| 20 |
completion: str,
|
| 21 |
gold_answer: str,
|
| 22 |
prompt_token_count: int,
|
| 23 |
-
llm_call_count: int,
|
| 24 |
cfg_reward: Any,
|
| 25 |
-
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
|
|
|
| 30 |
"""
|
| 31 |
-
|
| 32 |
-
if
|
| 33 |
-
return 0.0
|
| 34 |
-
|
| 35 |
-
c = correctness(completion, gold_answer)
|
| 36 |
t = token_budget(
|
| 37 |
completion,
|
| 38 |
prompt_token_count,
|
| 39 |
alpha=float(cfg_reward.alpha),
|
| 40 |
variant=str(cfg_reward.token_budget_variant),
|
| 41 |
)
|
| 42 |
-
r = recursion_efficiency(
|
| 43 |
-
int(llm_call_count),
|
| 44 |
-
max_calls=int(cfg_reward.get("max_calls", 2)) if hasattr(cfg_reward, "get") else int(getattr(cfg_reward, "max_calls", 2)),
|
| 45 |
-
)
|
| 46 |
-
w = cfg_reward.weights
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
composite = (
|
| 53 |
float(w.correctness) * c
|
| 54 |
+ float(w.token_budget) * t
|
| 55 |
-
+ float(w.recursion_efficiency) *
|
| 56 |
)
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
# A-02 fix: cap composite at 0.05 when correctness == 0
|
| 59 |
-
# (prevents format-only completions from scoring ~0.25 via token_budget + recursion)
|
| 60 |
if c == 0.0:
|
| 61 |
-
|
| 62 |
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
def compose_reward_fn(prompts: list, completions: list, **kwargs) -> list[float]:
|
| 67 |
-
"""TRL-compatible batched reward function
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
llm_call_count: list[int]
|
| 73 |
-
cfg_reward: OmegaConf DictConfig slice (injected via make_reward_fn partial)
|
| 74 |
"""
|
| 75 |
cfg_reward = kwargs.pop("cfg_reward")
|
| 76 |
gold_answers = kwargs.get("gold_answer", [""] * len(completions))
|
| 77 |
ptcs = kwargs.get("prompt_token_count", [1] * len(completions))
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
compose_reward_single(c, g, int(p),
|
| 81 |
-
for c, g, p
|
| 82 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
def make_reward_fn(cfg_reward: Any) -> Callable:
|
| 86 |
-
"""Factory
|
| 87 |
def _bound(prompts, completions, **kwargs):
|
| 88 |
kwargs["cfg_reward"] = cfg_reward
|
| 89 |
return compose_reward_fn(prompts, completions, **kwargs)
|
|
|
|
| 1 |
+
"""Reward composition — REW-02 v3.
|
| 2 |
+
|
| 3 |
+
Changes from v2 (the §A.3.2 "soft format bonus" patch):
|
| 4 |
+
- llm_call_count is now extracted from the completion's fenced Python
|
| 5 |
+
code blocks (via rewards.recursion_extract.count_llm_calls), not
|
| 6 |
+
hardcoded to 0 in train/grpo.py.
|
| 7 |
+
- Recursion efficiency is gated on correctness — wrong answers cannot
|
| 8 |
+
earn an efficiency bonus. Prevents the model from spamming `llm(`
|
| 9 |
+
strings to harvest free reward.
|
| 10 |
+
- Weights rebalanced: 0.70 correctness / 0.15 token_budget /
|
| 11 |
+
0.15 recursion_efficiency. (Was 0.75 / 0.20 / 0.05.)
|
| 12 |
+
- Per-component scalars are returned alongside the composite via the
|
| 13 |
+
`_metrics` dict so the GRPOTrainer wrapper in train/grpo.py can
|
| 14 |
+
log real per-component means to W&B (currently logs 0.0).
|
| 15 |
+
|
| 16 |
+
Anti-hacking caps preserved:
|
| 17 |
+
- c == 0.0 → composite ≤ 0.25 (was 0.05; raised to allow soft-format
|
| 18 |
+
bonus to register, still well below any correct answer ≥ 0.80).
|
| 19 |
"""
|
| 20 |
from __future__ import annotations
|
| 21 |
|
|
|
|
| 25 |
from .correctness import correctness
|
| 26 |
from .token_budget import token_budget
|
| 27 |
from .recursion_efficiency import recursion_efficiency
|
| 28 |
+
from .recursion_extract import count_llm_calls
|
| 29 |
|
| 30 |
|
| 31 |
def compose_reward_single(
|
| 32 |
completion: str,
|
| 33 |
gold_answer: str,
|
| 34 |
prompt_token_count: int,
|
|
|
|
| 35 |
cfg_reward: Any,
|
| 36 |
+
llm_call_count: int | None = None, # if None → extract from completion
|
| 37 |
+
) -> tuple[float, dict[str, float]]:
|
| 38 |
+
"""Single-example composite reward + per-component metrics.
|
| 39 |
|
| 40 |
+
Returns (composite_score, metrics_dict). The metrics dict has keys:
|
| 41 |
+
format_pass, correctness, token_budget, recursion_eff_raw,
|
| 42 |
+
recursion_eff_contribution, llm_call_count.
|
| 43 |
"""
|
| 44 |
+
has_format = format_gate(completion) == 1.0
|
| 45 |
+
c = correctness(completion, gold_answer) if has_format else 0.0
|
|
|
|
|
|
|
|
|
|
| 46 |
t = token_budget(
|
| 47 |
completion,
|
| 48 |
prompt_token_count,
|
| 49 |
alpha=float(cfg_reward.alpha),
|
| 50 |
variant=str(cfg_reward.token_budget_variant),
|
| 51 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
+
if llm_call_count is None:
|
| 54 |
+
llm_call_count = count_llm_calls(completion)
|
| 55 |
+
eff_raw = recursion_efficiency(int(llm_call_count))
|
| 56 |
+
# Couple efficiency to correctness — wrong answers earn 0 efficiency.
|
| 57 |
+
eff_contribution = eff_raw if c == 1.0 else 0.0
|
| 58 |
+
|
| 59 |
+
w = cfg_reward.weights
|
| 60 |
+
assert abs(
|
| 61 |
+
float(w.correctness) + float(w.token_budget) + float(w.recursion_efficiency) - 1.0
|
| 62 |
+
) < 1e-3, "REW-02 v3: composite weights must sum to 1.0"
|
| 63 |
|
| 64 |
composite = (
|
| 65 |
float(w.correctness) * c
|
| 66 |
+ float(w.token_budget) * t
|
| 67 |
+
+ float(w.recursion_efficiency) * eff_contribution
|
| 68 |
)
|
| 69 |
+
if has_format:
|
| 70 |
+
composite += 0.10 # soft format bonus (§A.3.2)
|
| 71 |
|
|
|
|
|
|
|
| 72 |
if c == 0.0:
|
| 73 |
+
composite = min(composite, 0.25)
|
| 74 |
|
| 75 |
+
metrics = {
|
| 76 |
+
"format_pass": 1.0 if has_format else 0.0,
|
| 77 |
+
"correctness": c,
|
| 78 |
+
"token_budget": t,
|
| 79 |
+
"recursion_eff_raw": eff_raw,
|
| 80 |
+
"recursion_eff_contribution": eff_contribution,
|
| 81 |
+
"llm_call_count": float(llm_call_count),
|
| 82 |
+
}
|
| 83 |
+
return composite, metrics
|
| 84 |
|
| 85 |
|
| 86 |
def compose_reward_fn(prompts: list, completions: list, **kwargs) -> list[float]:
|
| 87 |
+
"""TRL-compatible batched reward function. Returns scalars only.
|
| 88 |
|
| 89 |
+
Per-component means are stashed under `kwargs['_component_means']` for
|
| 90 |
+
the GRPOTrainer instrumentation wrapper to log to W&B. (TRL ignores
|
| 91 |
+
extra kwargs.)
|
|
|
|
|
|
|
| 92 |
"""
|
| 93 |
cfg_reward = kwargs.pop("cfg_reward")
|
| 94 |
gold_answers = kwargs.get("gold_answer", [""] * len(completions))
|
| 95 |
ptcs = kwargs.get("prompt_token_count", [1] * len(completions))
|
| 96 |
+
|
| 97 |
+
pairs = [
|
| 98 |
+
compose_reward_single(c, g, int(p), cfg_reward)
|
| 99 |
+
for c, g, p in zip(completions, gold_answers, ptcs)
|
| 100 |
]
|
| 101 |
+
rewards = [p[0] for p in pairs]
|
| 102 |
+
metrics_list = [p[1] for p in pairs]
|
| 103 |
+
|
| 104 |
+
# Aggregate component means for W&B logging via the wrapper.
|
| 105 |
+
if metrics_list:
|
| 106 |
+
keys = metrics_list[0].keys()
|
| 107 |
+
means = {k: sum(m[k] for m in metrics_list) / len(metrics_list) for k in keys}
|
| 108 |
+
kwargs["_component_means"] = means
|
| 109 |
+
return rewards
|
| 110 |
|
| 111 |
|
| 112 |
def make_reward_fn(cfg_reward: Any) -> Callable:
|
| 113 |
+
"""Factory binding cfg_reward for GRPOTrainer.reward_funcs."""
|
| 114 |
def _bound(prompts, completions, **kwargs):
|
| 115 |
kwargs["cfg_reward"] = cfg_reward
|
| 116 |
return compose_reward_fn(prompts, completions, **kwargs)
|
rewards/recursion_efficiency.py
CHANGED
|
@@ -1,19 +1,23 @@
|
|
| 1 |
-
"""Recursion efficiency reward component — REW-
|
| 2 |
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
_STAIRCASE = {0: 1.0, 1: 0.7, 2: 0.4}
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
"""
|
| 13 |
count = max(0, int(llm_call_count))
|
| 14 |
-
|
| 15 |
-
return 0.0
|
| 16 |
-
return _STAIRCASE.get(count, 0.0)
|
| 17 |
|
| 18 |
|
| 19 |
__all__ = ["recursion_efficiency"]
|
|
|
|
| 1 |
+
"""Recursion efficiency reward component — REW-04 v2.
|
| 2 |
|
| 3 |
+
Linear decay on llm_call_count. Pure Python, stdlib-only.
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
Intended ranges:
|
| 6 |
+
0 calls → 1.0 (best — task didn't need recursion)
|
| 7 |
+
1 call → 0.75
|
| 8 |
+
2 calls → 0.50
|
| 9 |
+
3 calls → 0.25
|
| 10 |
+
4+ calls → 0.00 (recursion spam is wasteful)
|
| 11 |
|
| 12 |
+
This score is *coupled to correctness* in compose.py — wrong answers don't
|
| 13 |
+
earn an efficiency bonus, which prevents the model from learning to spam
|
| 14 |
+
`llm(` strings in code blocks for free reward.
|
| 15 |
+
"""
|
| 16 |
|
| 17 |
+
def recursion_efficiency(llm_call_count: int, **_) -> float:
|
| 18 |
+
"""Linear-decay efficiency on call count; gated to correctness in compose."""
|
| 19 |
count = max(0, int(llm_call_count))
|
| 20 |
+
return max(0.0, 1.0 - 0.25 * count)
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
__all__ = ["recursion_efficiency"]
|
rewards/recursion_extract.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Recursion call extractor — REW-04 v2.
|
| 2 |
+
|
| 3 |
+
Counts llm( function calls inside fenced ```python code blocks of a model
|
| 4 |
+
completion. Ignores occurrences in:
|
| 5 |
+
- prose outside any code block
|
| 6 |
+
- comments inside a code block (# llm(...) → 0)
|
| 7 |
+
- string literals inside a code block ("did llm(...)" → 0)
|
| 8 |
+
|
| 9 |
+
Uses Python's tokenize module for accuracy; regex fallback when the code
|
| 10 |
+
block is syntactically invalid (the model writes broken Python sometimes
|
| 11 |
+
but we still want to count its intent).
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import io
|
| 16 |
+
import re
|
| 17 |
+
import tokenize
|
| 18 |
+
|
| 19 |
+
# Match ``` or ```python or ```py — case-insensitive, multi-line.
|
| 20 |
+
_CODE_BLOCK_RE = re.compile(
|
| 21 |
+
r"```(?:python|py)?\s*\n(.*?)```",
|
| 22 |
+
re.DOTALL | re.IGNORECASE,
|
| 23 |
+
)
|
| 24 |
+
_LLM_CALL_RE = re.compile(r"\bllm\s*\(")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _count_in_block(code: str) -> int:
|
| 28 |
+
"""Count llm( calls in one code block. Tokenize-aware; regex fallback."""
|
| 29 |
+
try:
|
| 30 |
+
toks = list(tokenize.generate_tokens(io.StringIO(code).readline))
|
| 31 |
+
except (tokenize.TokenizeError, IndentationError, SyntaxError):
|
| 32 |
+
# Strip line comments, then regex. Conservative — does not strip
|
| 33 |
+
# string literals, but the model rarely puts llm( in a string when
|
| 34 |
+
# writing broken code.
|
| 35 |
+
stripped = "\n".join(line.split("#", 1)[0] for line in code.splitlines())
|
| 36 |
+
return len(_LLM_CALL_RE.findall(stripped))
|
| 37 |
+
|
| 38 |
+
count = 0
|
| 39 |
+
for i in range(len(toks) - 1):
|
| 40 |
+
tok = toks[i]
|
| 41 |
+
nxt = toks[i + 1]
|
| 42 |
+
if (
|
| 43 |
+
tok.type == tokenize.NAME
|
| 44 |
+
and tok.string == "llm"
|
| 45 |
+
and nxt.type == tokenize.OP
|
| 46 |
+
and nxt.string == "("
|
| 47 |
+
):
|
| 48 |
+
count += 1
|
| 49 |
+
return count
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def count_llm_calls(completion: str) -> int:
|
| 53 |
+
"""Total llm( calls inside all fenced code blocks of the completion.
|
| 54 |
+
|
| 55 |
+
Returns 0 if completion is empty, has no code blocks, or only contains
|
| 56 |
+
llm( in prose / comments / strings.
|
| 57 |
+
"""
|
| 58 |
+
if not completion:
|
| 59 |
+
return 0
|
| 60 |
+
blocks = _CODE_BLOCK_RE.findall(completion)
|
| 61 |
+
if not blocks:
|
| 62 |
+
return 0
|
| 63 |
+
return sum(_count_in_block(b) for b in blocks)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
__all__ = ["count_llm_calls"]
|
scripts/verify_recursion_reward.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CPU-only verification of REW-04 v2 reward design.
|
| 2 |
+
|
| 3 |
+
Confirms two GRPO-blocking properties:
|
| 4 |
+
1. A synthetic 8-completion group produces non-zero std (v1's std was 0.0
|
| 5 |
+
across every group, which is why the reward curve was flat).
|
| 6 |
+
2. The ordering correct+0calls > correct+1call > correct+spam holds.
|
| 7 |
+
|
| 8 |
+
Run BEFORE spending any HF Jobs credits on a retrain.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import statistics
|
| 13 |
+
import types
|
| 14 |
+
|
| 15 |
+
from rewards.compose import compose_reward_single
|
| 16 |
+
|
| 17 |
+
cfg = types.SimpleNamespace(
|
| 18 |
+
alpha=0.2,
|
| 19 |
+
weights=types.SimpleNamespace(
|
| 20 |
+
correctness=0.70, token_budget=0.15, recursion_efficiency=0.15
|
| 21 |
+
),
|
| 22 |
+
token_budget_variant="capped_linear",
|
| 23 |
+
answer_regex="<answer>(.*?)</answer>",
|
| 24 |
+
max_calls=4,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
GOLD = "silver"
|
| 28 |
+
GENERATIONS = [
|
| 29 |
+
("correct + 0 llm calls (REPL grep)",
|
| 30 |
+
"```python\nimport re\nm=re.search('silver', ctx)\nprint(m.group())\n```\n<answer>silver</answer>"),
|
| 31 |
+
("correct + 1 llm call",
|
| 32 |
+
"```python\nans=llm('color', ctx[:5000])\nprint(ans)\n```\n<answer>silver</answer>"),
|
| 33 |
+
("correct + 3 llm calls (wasteful)",
|
| 34 |
+
"```python\na=llm('q1',ctx[:1000])\nb=llm('q2',ctx[1000:2000])\nc=llm('q3',ctx[2000:3000])\n```\n<answer>silver</answer>"),
|
| 35 |
+
("correct + bare answer (no code, trivial-task path)",
|
| 36 |
+
"<answer>silver</answer>"),
|
| 37 |
+
("wrong + format",
|
| 38 |
+
"<answer>gold</answer>"),
|
| 39 |
+
("wrong + no format",
|
| 40 |
+
"the color is gold"),
|
| 41 |
+
("right text + no format (v1 collapse mode)",
|
| 42 |
+
"silver"),
|
| 43 |
+
("format-only spam",
|
| 44 |
+
"<answer></answer>"),
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
print(f"{'idx':>3} {'score':>6} {'calls':>5} description")
|
| 48 |
+
print("-" * 78)
|
| 49 |
+
scores = []
|
| 50 |
+
for i, (desc, gen) in enumerate(GENERATIONS):
|
| 51 |
+
s, m = compose_reward_single(gen, GOLD, 200, cfg)
|
| 52 |
+
scores.append(s)
|
| 53 |
+
print(f"{i:>3} {s:>6.3f} {int(m['llm_call_count']):>5d} {desc}")
|
| 54 |
+
print("-" * 78)
|
| 55 |
+
print(f"group mean: {statistics.mean(scores):.4f}")
|
| 56 |
+
print(f"group std: {statistics.stdev(scores):.4f} (must be > 0.10 for GRPO advantage)")
|
| 57 |
+
print(f"max - min: {max(scores) - min(scores):.4f}")
|
| 58 |
+
|
| 59 |
+
# Hard gates — exit non-zero if any fail
|
| 60 |
+
assert statistics.stdev(scores) > 0.10, "FAIL: group std too low; GRPO will not learn"
|
| 61 |
+
assert scores[0] > scores[1] > scores[2], (
|
| 62 |
+
f"FAIL: efficiency ordering broken (got {scores[0]:.3f} > {scores[1]:.3f} > {scores[2]:.3f})"
|
| 63 |
+
)
|
| 64 |
+
assert scores[0] > scores[4], "FAIL: correct must beat wrong"
|
| 65 |
+
assert scores[7] <= 0.25, "FAIL: format-only spam not capped"
|
| 66 |
+
print("\nPASS: REW-04 v2 produces learnable variance and correct orderings")
|
tests/test_recursion_extract.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for rewards.recursion_extract — REW-04 v2."""
|
| 2 |
+
from rewards.recursion_extract import count_llm_calls
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def test_empty_completion():
|
| 6 |
+
assert count_llm_calls("") == 0
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_no_code_block():
|
| 10 |
+
assert count_llm_calls("The answer is <answer>silver</answer>") == 0
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_single_call():
|
| 14 |
+
c = "```python\nresult = llm('find', ctx[:5000])\n```\n<answer>silver</answer>"
|
| 15 |
+
assert count_llm_calls(c) == 1
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_two_calls_in_one_block():
|
| 19 |
+
c = "```python\na = llm('q1', ctx[:1000])\nb = llm('q2', ctx[1000:])\n```"
|
| 20 |
+
assert count_llm_calls(c) == 2
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_calls_across_two_blocks():
|
| 24 |
+
c = "```python\nx=llm('q', ctx)\n```\nthen\n```python\ny=llm('q2', ctx)\n```"
|
| 25 |
+
assert count_llm_calls(c) == 2
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_call_in_comment_not_counted():
|
| 29 |
+
c = "```python\n# would call llm(stuff) but skipping\nprint('done')\n```"
|
| 30 |
+
assert count_llm_calls(c) == 0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_call_in_string_literal_not_counted():
|
| 34 |
+
c = '```python\nnote = "earlier code did llm(...)"\nprint(note)\n```'
|
| 35 |
+
assert count_llm_calls(c) == 0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_call_outside_code_block_not_counted():
|
| 39 |
+
c = "Maybe I should call llm(question, chunk) but I won't actually."
|
| 40 |
+
assert count_llm_calls(c) == 0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_call_in_loop_counts_literal_occurrence():
|
| 44 |
+
c = "```python\nfor chunk in chunks:\n r = llm('find', chunk)\n```"
|
| 45 |
+
assert count_llm_calls(c) == 1
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_invalid_python_falls_back_to_regex():
|
| 49 |
+
c = "```python\nthis is not valid python !!!\nresult = llm('q', ctx)\n```"
|
| 50 |
+
assert count_llm_calls(c) >= 1 # fallback regex finds it
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_bare_python_fence():
|
| 54 |
+
c = "```\nans = llm('q', ctx)\n```" # no language tag
|
| 55 |
+
assert count_llm_calls(c) == 1
|
tests/test_rewards.py
CHANGED
|
@@ -135,25 +135,24 @@ class TestRecursionEfficiency:
|
|
| 135 |
assert recursion_efficiency(0) == 1.0
|
| 136 |
|
| 137 |
def test_one_call(self):
|
| 138 |
-
assert recursion_efficiency(1) == 0.
|
| 139 |
|
| 140 |
def test_two_calls(self):
|
| 141 |
-
assert recursion_efficiency(2) == 0.
|
| 142 |
|
| 143 |
-
def
|
| 144 |
-
assert recursion_efficiency(3) == 0.
|
| 145 |
|
| 146 |
-
def
|
|
|
|
| 147 |
assert recursion_efficiency(10) == 0.0
|
| 148 |
|
| 149 |
def test_negative_treated_as_zero(self):
|
| 150 |
assert recursion_efficiency(-1) == 1.0
|
| 151 |
|
| 152 |
-
def
|
| 153 |
-
# max_calls
|
| 154 |
-
assert recursion_efficiency(
|
| 155 |
-
# With max_calls=4, 3 calls return staircase 0.0 (not in map) → 0.0
|
| 156 |
-
assert recursion_efficiency(2, max_calls=4) == 0.4 # <=max_calls, staircase applies
|
| 157 |
|
| 158 |
|
| 159 |
# ---------------------------------------------------------------------------
|
|
@@ -162,24 +161,24 @@ class TestRecursionEfficiency:
|
|
| 162 |
|
| 163 |
class TestCompose:
|
| 164 |
def test_format_fail_short_circuits(self, cfg_reward):
|
| 165 |
-
score = compose_reward_single("no tag here", "Rome", 100,
|
| 166 |
-
assert score =
|
| 167 |
|
| 168 |
def test_perfect_score(self, cfg_reward):
|
| 169 |
# Short completion, 0 llm calls, correct answer
|
| 170 |
-
score = compose_reward_single("<answer>Rome</answer>", "Rome", 1,
|
| 171 |
-
assert score == pytest.approx(0.75 * 1.0 + 0.2 * 1.0 + 0.05 * 1.0, abs=0.05)
|
| 172 |
|
| 173 |
-
def
|
| 174 |
-
#
|
| 175 |
-
score = compose_reward_single("<answer>Paris</answer>", "Rome", 100,
|
| 176 |
-
assert score <= 0.
|
| 177 |
|
| 178 |
def test_partial_mix(self, cfg_reward):
|
| 179 |
# Correct answer but long completion (token_budget reduced)
|
| 180 |
long_text = " ".join(["filler"] * 500) + " <answer>42</answer>"
|
| 181 |
-
score = compose_reward_single(long_text, "42", 10,
|
| 182 |
-
assert 0.5 < score <= 1.
|
| 183 |
|
| 184 |
def test_make_reward_fn_factory(self, cfg_reward):
|
| 185 |
bound_fn = make_reward_fn(cfg_reward)
|
|
@@ -204,7 +203,7 @@ class TestCompose:
|
|
| 204 |
)
|
| 205 |
assert len(results) == 2
|
| 206 |
assert results[0] > 0.0
|
| 207 |
-
assert results[1] =
|
| 208 |
|
| 209 |
|
| 210 |
# ---------------------------------------------------------------------------
|
|
@@ -213,28 +212,28 @@ class TestCompose:
|
|
| 213 |
|
| 214 |
@pytest.mark.reward_audit
|
| 215 |
def test_masked_context_reward_is_baseline(cfg_reward):
|
| 216 |
-
"""REW-06: gibberish answer with format tag must score <= 0.
|
| 217 |
-
score = compose_reward_single(
|
| 218 |
"<answer>lorem ipsum dolor sit amet</answer>",
|
| 219 |
"Rome",
|
| 220 |
100,
|
| 221 |
-
0,
|
| 222 |
cfg_reward,
|
|
|
|
| 223 |
)
|
| 224 |
-
assert score <= 0.
|
| 225 |
|
| 226 |
|
| 227 |
@pytest.mark.reward_audit
|
| 228 |
def test_format_only_composite_is_capped(cfg_reward):
|
| 229 |
-
"""REW-06: 20 format-only completions with mismatched gold must all score <= 0.
|
| 230 |
words = ["banana", "kiwi", "mango", "peach", "grape", "lemon", "plum",
|
| 231 |
"cherry", "pear", "melon", "papaya", "guava", "fig", "date",
|
| 232 |
"lime", "apricot", "coconut", "blueberry", "raspberry", "strawberry"]
|
| 233 |
composites = [
|
| 234 |
-
compose_reward_single(f"<answer>{w}</answer>", "Rome", 100,
|
| 235 |
for w in words
|
| 236 |
]
|
| 237 |
-
assert max(composites) <= 0.
|
| 238 |
|
| 239 |
|
| 240 |
@pytest.mark.reward_audit
|
|
@@ -244,7 +243,7 @@ def test_no_monotonic_length_exploit(cfg_reward):
|
|
| 244 |
composites = []
|
| 245 |
for n in lengths:
|
| 246 |
completion = " ".join(["filler"] * n) + " <answer>rome</answer>"
|
| 247 |
-
score = compose_reward_single(completion, "rome", 100,
|
| 248 |
composites.append(score)
|
| 249 |
|
| 250 |
# Longer completions must not score strictly higher
|
|
@@ -255,3 +254,71 @@ def test_no_monotonic_length_exploit(cfg_reward):
|
|
| 255 |
assert all(a >= b for a, b in zip(composites, composites[1:])), (
|
| 256 |
f"Non-monotonic sequence: {[round(x, 3) for x in composites]}"
|
| 257 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
assert recursion_efficiency(0) == 1.0
|
| 136 |
|
| 137 |
def test_one_call(self):
|
| 138 |
+
assert recursion_efficiency(1) == 0.75
|
| 139 |
|
| 140 |
def test_two_calls(self):
|
| 141 |
+
assert recursion_efficiency(2) == 0.50
|
| 142 |
|
| 143 |
+
def test_three_calls(self):
|
| 144 |
+
assert recursion_efficiency(3) == 0.25
|
| 145 |
|
| 146 |
+
def test_four_or_more_calls_zero(self):
|
| 147 |
+
assert recursion_efficiency(4) == 0.0
|
| 148 |
assert recursion_efficiency(10) == 0.0
|
| 149 |
|
| 150 |
def test_negative_treated_as_zero(self):
|
| 151 |
assert recursion_efficiency(-1) == 1.0
|
| 152 |
|
| 153 |
+
def test_max_calls_ignored_in_v2(self):
|
| 154 |
+
# max_calls kwarg is accepted via **_ but ignored by new linear decay
|
| 155 |
+
assert recursion_efficiency(2, max_calls=1) == 0.50
|
|
|
|
|
|
|
| 156 |
|
| 157 |
|
| 158 |
# ---------------------------------------------------------------------------
|
|
|
|
| 161 |
|
| 162 |
class TestCompose:
|
| 163 |
def test_format_fail_short_circuits(self, cfg_reward):
|
| 164 |
+
score, _ = compose_reward_single("no tag here", "Rome", 100, cfg_reward, llm_call_count=0)
|
| 165 |
+
assert 0.0 <= score <= 0.25 # A-02 cap applies, but > 0 because of token budget
|
| 166 |
|
| 167 |
def test_perfect_score(self, cfg_reward):
|
| 168 |
# Short completion, 0 llm calls, correct answer
|
| 169 |
+
score, _ = compose_reward_single("<answer>Rome</answer>", "Rome", 1, cfg_reward, llm_call_count=0)
|
| 170 |
+
assert score == pytest.approx(0.75 * 1.0 + 0.2 * 1.0 + 0.05 * 1.0 + 0.10, abs=0.05)
|
| 171 |
|
| 172 |
+
def test_correctness_zero_capped_at_025(self, cfg_reward):
|
| 173 |
+
# format passes but wrong answer → capped at 0.25 (since format bonus + cap)
|
| 174 |
+
score, _ = compose_reward_single("<answer>Paris</answer>", "Rome", 100, cfg_reward, llm_call_count=0)
|
| 175 |
+
assert score <= 0.25
|
| 176 |
|
| 177 |
def test_partial_mix(self, cfg_reward):
|
| 178 |
# Correct answer but long completion (token_budget reduced)
|
| 179 |
long_text = " ".join(["filler"] * 500) + " <answer>42</answer>"
|
| 180 |
+
score, _ = compose_reward_single(long_text, "42", 10, cfg_reward, llm_call_count=0)
|
| 181 |
+
assert 0.5 < score <= 1.10 # correctness=1 dominates
|
| 182 |
|
| 183 |
def test_make_reward_fn_factory(self, cfg_reward):
|
| 184 |
bound_fn = make_reward_fn(cfg_reward)
|
|
|
|
| 203 |
)
|
| 204 |
assert len(results) == 2
|
| 205 |
assert results[0] > 0.0
|
| 206 |
+
assert results[1] <= 0.25
|
| 207 |
|
| 208 |
|
| 209 |
# ---------------------------------------------------------------------------
|
|
|
|
| 212 |
|
| 213 |
@pytest.mark.reward_audit
|
| 214 |
def test_masked_context_reward_is_baseline(cfg_reward):
|
| 215 |
+
"""REW-06: gibberish answer with format tag must score <= 0.25 (A-02 cap)."""
|
| 216 |
+
score, _ = compose_reward_single(
|
| 217 |
"<answer>lorem ipsum dolor sit amet</answer>",
|
| 218 |
"Rome",
|
| 219 |
100,
|
|
|
|
| 220 |
cfg_reward,
|
| 221 |
+
llm_call_count=0,
|
| 222 |
)
|
| 223 |
+
assert score <= 0.25, f"Masked-context score too high: {score}"
|
| 224 |
|
| 225 |
|
| 226 |
@pytest.mark.reward_audit
|
| 227 |
def test_format_only_composite_is_capped(cfg_reward):
|
| 228 |
+
"""REW-06: 20 format-only completions with mismatched gold must all score <= 0.25."""
|
| 229 |
words = ["banana", "kiwi", "mango", "peach", "grape", "lemon", "plum",
|
| 230 |
"cherry", "pear", "melon", "papaya", "guava", "fig", "date",
|
| 231 |
"lime", "apricot", "coconut", "blueberry", "raspberry", "strawberry"]
|
| 232 |
composites = [
|
| 233 |
+
compose_reward_single(f"<answer>{w}</answer>", "Rome", 100, cfg_reward, llm_call_count=0)[0]
|
| 234 |
for w in words
|
| 235 |
]
|
| 236 |
+
assert max(composites) <= 0.25, f"Format-only composites: {composites}"
|
| 237 |
|
| 238 |
|
| 239 |
@pytest.mark.reward_audit
|
|
|
|
| 243 |
composites = []
|
| 244 |
for n in lengths:
|
| 245 |
completion = " ".join(["filler"] * n) + " <answer>rome</answer>"
|
| 246 |
+
score, _ = compose_reward_single(completion, "rome", 100, cfg_reward, llm_call_count=0)
|
| 247 |
composites.append(score)
|
| 248 |
|
| 249 |
# Longer completions must not score strictly higher
|
|
|
|
| 254 |
assert all(a >= b for a, b in zip(composites, composites[1:])), (
|
| 255 |
f"Non-monotonic sequence: {[round(x, 3) for x in composites]}"
|
| 256 |
)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
class TestComposeV3:
|
| 260 |
+
"""REW-02 v3: soft format + recursion-extraction + correctness-gated efficiency."""
|
| 261 |
+
|
| 262 |
+
@pytest.fixture
|
| 263 |
+
def cfg_v3(self):
|
| 264 |
+
return OmegaConf.create({
|
| 265 |
+
"alpha": 0.2,
|
| 266 |
+
"weights": {"correctness": 0.70, "token_budget": 0.15, "recursion_efficiency": 0.15},
|
| 267 |
+
"token_budget_variant": "capped_linear",
|
| 268 |
+
"answer_regex": "<answer>(.*?)</answer>",
|
| 269 |
+
"max_calls": 4,
|
| 270 |
+
})
|
| 271 |
+
|
| 272 |
+
def test_correct_no_recursion_scores_high(self, cfg_v3):
|
| 273 |
+
c = "```python\nimport re\nm=re.search('silver', ctx)\nprint(m.group())\n```\n<answer>silver</answer>"
|
| 274 |
+
score, metrics = compose_reward_single(c, "silver", 100, cfg_v3)
|
| 275 |
+
assert score >= 0.85, f"clean correct should score high, got {score}"
|
| 276 |
+
assert metrics["llm_call_count"] == 0
|
| 277 |
+
|
| 278 |
+
def test_zero_calls_beats_one_call_when_both_correct(self, cfg_v3):
|
| 279 |
+
c0 = "```python\nimport re\nm=re.search('silver', ctx)\nprint(m.group())\n```\n<answer>silver</answer>"
|
| 280 |
+
c1 = "```python\nans=llm('color', ctx[:5000])\nprint(ans)\n```\n<answer>silver</answer>"
|
| 281 |
+
s0, _ = compose_reward_single(c0, "silver", 100, cfg_v3)
|
| 282 |
+
s1, _ = compose_reward_single(c1, "silver", 100, cfg_v3)
|
| 283 |
+
assert s0 > s1, f"0-call ({s0:.3f}) should beat 1-call ({s1:.3f}) when both correct"
|
| 284 |
+
|
| 285 |
+
def test_efficiency_gated_on_correctness(self, cfg_v3):
|
| 286 |
+
# Wrong answer with 0 calls — must NOT earn efficiency bonus.
|
| 287 |
+
c = "```python\nprint('done')\n```\n<answer>gold</answer>"
|
| 288 |
+
score, metrics = compose_reward_single(c, "silver", 100, cfg_v3)
|
| 289 |
+
assert metrics["recursion_eff_contribution"] == 0.0
|
| 290 |
+
assert score <= 0.25, f"wrong answer must be capped, got {score}"
|
| 291 |
+
|
| 292 |
+
def test_recursion_spam_loses_to_minimal_recursion(self, cfg_v3):
|
| 293 |
+
c2 = "```python\na=llm('q1',ctx[:1000])\nb=llm('q2',ctx[1000:2000])\n```\n<answer>silver</answer>"
|
| 294 |
+
c5 = "```python\n" + "\n".join(f"x{i}=llm('q{i}',ctx)" for i in range(5)) + "\n```\n<answer>silver</answer>"
|
| 295 |
+
s2, _ = compose_reward_single(c2, "silver", 100, cfg_v3)
|
| 296 |
+
s5, _ = compose_reward_single(c5, "silver", 100, cfg_v3)
|
| 297 |
+
assert s2 > s5, f"2-call ({s2:.3f}) should beat 5-call spam ({s5:.3f})"
|
| 298 |
+
|
| 299 |
+
def test_format_only_capped(self, cfg_v3):
|
| 300 |
+
c = "<answer>wrong</answer>"
|
| 301 |
+
score, _ = compose_reward_single(c, "silver", 100, cfg_v3)
|
| 302 |
+
assert 0.05 <= score <= 0.25, f"format-only wrong should be in [0.05, 0.25], got {score}"
|
| 303 |
+
|
| 304 |
+
def test_no_format_gets_minimal_credit(self, cfg_v3):
|
| 305 |
+
c = "silver" # right text but no <answer> tag
|
| 306 |
+
score, _ = compose_reward_single(c, "silver", 100, cfg_v3)
|
| 307 |
+
assert score <= 0.20
|
| 308 |
+
|
| 309 |
+
def test_group_variance_nonzero(self, cfg_v3):
|
| 310 |
+
"""Smoke check: a synthetic GRPO group of 8 must produce non-zero std.
|
| 311 |
+
v1 had std=0.0 across all groups, which zeroed the GRPO advantage."""
|
| 312 |
+
gens = [
|
| 313 |
+
"```python\nimport re\nm=re.search('silver',ctx)\nprint(m.group())\n```\n<answer>silver</answer>",
|
| 314 |
+
"```python\nans=llm('color',ctx[:5000])\nprint(ans)\n```\n<answer>silver</answer>",
|
| 315 |
+
"<answer>silver</answer>",
|
| 316 |
+
"<answer>gold</answer>",
|
| 317 |
+
"the color is silver",
|
| 318 |
+
"<answer></answer>",
|
| 319 |
+
"```python\n" + "\n".join(f"x{i}=llm('q{i}',ctx)" for i in range(5)) + "\n```\n<answer>silver</answer>",
|
| 320 |
+
"silver.",
|
| 321 |
+
]
|
| 322 |
+
scores = [compose_reward_single(g, "silver", 200, cfg_v3)[0] for g in gens]
|
| 323 |
+
import statistics as _st
|
| 324 |
+
assert _st.stdev(scores) > 0.10, f"group std too low: {_st.stdev(scores)}"
|
train/grpo.py
CHANGED
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
| 16 |
import logging
|
| 17 |
import os
|
| 18 |
import inspect
|
|
|
|
| 19 |
from pathlib import Path
|
| 20 |
from typing import Any, Callable
|
| 21 |
|
|
@@ -139,15 +140,18 @@ def run_grpo(
|
|
| 139 |
rewards = reward_fn(prompts, completions, **kwargs)
|
| 140 |
try:
|
| 141 |
if wandb.run is not None:
|
| 142 |
-
|
| 143 |
"reward/composite_mean": sum(rewards) / max(len(rewards), 1),
|
| 144 |
-
"reward/
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
})
|
|
|
|
|
|
|
|
|
|
| 149 |
except Exception:
|
| 150 |
-
pass
|
| 151 |
return rewards
|
| 152 |
|
| 153 |
# TRN-03 step 3: Build train_dataset from data/train.jsonl.
|
|
@@ -184,12 +188,7 @@ def run_grpo(
|
|
| 184 |
# if the completion lacks <answer>...</answer>, composite reward = 0.0.
|
| 185 |
# Previous wording ("shortest exact answer span") never told the model
|
| 186 |
# about the tag, so 50/50 GRPO steps had reward=0.0 (job 69ece94a).
|
| 187 |
-
sys_msg = (
|
| 188 |
-
"You are FATHOM, a recursive language model. You answer questions "
|
| 189 |
-
"about long documents. Read the context, think step by step, and "
|
| 190 |
-
"emit your final answer inside <answer>...</answer> tags. "
|
| 191 |
-
"Keep the answer the shortest exact span that answers the question."
|
| 192 |
-
)
|
| 193 |
|
| 194 |
# vLLM hard-checks final prompt length against the model's max_position_embeddings
|
| 195 |
# (32 768 for Qwen 2.5 Coder 0.5B/1.5B). Our train.jsonl `context` field is the
|
|
@@ -218,15 +217,14 @@ def run_grpo(
|
|
| 218 |
def _to_prompt(example: dict) -> dict:
|
| 219 |
ctx_full = example.get("context", "") or ""
|
| 220 |
ctx_truncated = _truncate_to_tokens(ctx_full, ctx_budget_tok)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
msgs = [
|
| 222 |
{"role": "system", "content": sys_msg},
|
| 223 |
-
{
|
| 224 |
-
"role": "user",
|
| 225 |
-
"content": (
|
| 226 |
-
f"Context:\n{ctx_truncated}\n\n"
|
| 227 |
-
f"{example.get('prompt','')}"
|
| 228 |
-
),
|
| 229 |
-
},
|
| 230 |
]
|
| 231 |
prompt_str = tokenizer.apply_chat_template(
|
| 232 |
msgs, tokenize=False, add_generation_prompt=True
|
|
@@ -235,7 +233,6 @@ def run_grpo(
|
|
| 235 |
"prompt": prompt_str,
|
| 236 |
"gold_answer": str(example.get("gold_answer", "")),
|
| 237 |
"prompt_token_count": int(example.get("context_length", 0)) // 4,
|
| 238 |
-
"llm_call_count": 0,
|
| 239 |
}
|
| 240 |
|
| 241 |
train_dataset = raw_ds.map(
|
|
@@ -259,6 +256,16 @@ def run_grpo(
|
|
| 259 |
log.info("TRN-03 GRPOTrainer constructed (no env tools — pure prompt→completion→reward)")
|
| 260 |
|
| 261 |
# TRN-03 step 4: Train
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
trainer.train()
|
| 263 |
|
| 264 |
# TRN-03 step 5: STACK §6 save sequence — adapter-only FIRST
|
|
|
|
| 16 |
import logging
|
| 17 |
import os
|
| 18 |
import inspect
|
| 19 |
+
import statistics
|
| 20 |
from pathlib import Path
|
| 21 |
from typing import Any, Callable
|
| 22 |
|
|
|
|
| 140 |
rewards = reward_fn(prompts, completions, **kwargs)
|
| 141 |
try:
|
| 142 |
if wandb.run is not None:
|
| 143 |
+
log_dict = {
|
| 144 |
"reward/composite_mean": sum(rewards) / max(len(rewards), 1),
|
| 145 |
+
"reward/composite_std": (
|
| 146 |
+
statistics.stdev(rewards) if len(rewards) > 1 else 0.0
|
| 147 |
+
),
|
| 148 |
+
}
|
| 149 |
+
cm = kwargs.get("_component_means", {})
|
| 150 |
+
for k, v in cm.items():
|
| 151 |
+
log_dict[f"reward/{k}_mean"] = float(v)
|
| 152 |
+
wandb.log(log_dict)
|
| 153 |
except Exception:
|
| 154 |
+
pass
|
| 155 |
return rewards
|
| 156 |
|
| 157 |
# TRN-03 step 3: Build train_dataset from data/train.jsonl.
|
|
|
|
| 188 |
# if the completion lacks <answer>...</answer>, composite reward = 0.0.
|
| 189 |
# Previous wording ("shortest exact answer span") never told the model
|
| 190 |
# about the tag, so 50/50 GRPO steps had reward=0.0 (job 69ece94a).
|
| 191 |
+
sys_msg = "You are FATHOM, a recursive language model with a Python REPL sandbox. You can read a long document via the variable `ctx` and call `llm(prompt, chunk)` for sub-queries. Think step by step. Emit your final answer inside <answer>...</answer>."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
# vLLM hard-checks final prompt length against the model's max_position_embeddings
|
| 194 |
# (32 768 for Qwen 2.5 Coder 0.5B/1.5B). Our train.jsonl `context` field is the
|
|
|
|
| 217 |
def _to_prompt(example: dict) -> dict:
|
| 218 |
ctx_full = example.get("context", "") or ""
|
| 219 |
ctx_truncated = _truncate_to_tokens(ctx_full, ctx_budget_tok)
|
| 220 |
+
# CRITICAL: must match data/sft_traces.jsonl user-message shape exactly.
|
| 221 |
+
user_content = (
|
| 222 |
+
f"{example.get('prompt', '')}\n\n"
|
| 223 |
+
f"[Document excerpt]:\n{ctx_truncated}"
|
| 224 |
+
)
|
| 225 |
msgs = [
|
| 226 |
{"role": "system", "content": sys_msg},
|
| 227 |
+
{"role": "user", "content": user_content},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
]
|
| 229 |
prompt_str = tokenizer.apply_chat_template(
|
| 230 |
msgs, tokenize=False, add_generation_prompt=True
|
|
|
|
| 233 |
"prompt": prompt_str,
|
| 234 |
"gold_answer": str(example.get("gold_answer", "")),
|
| 235 |
"prompt_token_count": int(example.get("context_length", 0)) // 4,
|
|
|
|
| 236 |
}
|
| 237 |
|
| 238 |
train_dataset = raw_ds.map(
|
|
|
|
| 256 |
log.info("TRN-03 GRPOTrainer constructed (no env tools — pure prompt→completion→reward)")
|
| 257 |
|
| 258 |
# TRN-03 step 4: Train
|
| 259 |
+
# Pre-flight: tokenize one example and confirm the chat-template prefix is
|
| 260 |
+
# the byte-identical match of an SFT trace prefix.
|
| 261 |
+
import json as _json
|
| 262 |
+
_sft = _json.loads(open(str(cfg.data.sft_traces_path) if hasattr(cfg.data, "sft_traces_path") else "data/sft_traces.jsonl", encoding="utf-8").readline())
|
| 263 |
+
_sft_prefix = tokenizer.apply_chat_template(_sft["messages"][:2], tokenize=False, add_generation_prompt=True)[:200]
|
| 264 |
+
_grpo_first = train_dataset[0]["prompt"][:200]
|
| 265 |
+
assert _sft_prefix.split("Question:")[0] == _grpo_first.split("Question:")[0], (
|
| 266 |
+
"SFT/GRPO chat-template prefix drift detected — see ANTIGRAVITY_BRIEF.md §A.3.1"
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
trainer.train()
|
| 270 |
|
| 271 |
# TRN-03 step 5: STACK §6 save sequence — adapter-only FIRST
|