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 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.05 (A-02 fix: correctness=0 caps composite)
20
 
21
- **Result:** 0.05 (matches cap) ✅
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 (avoid all tool calls)
76
 
77
- **Vector:** Model uses 0 llm() calls (skips recursion entirely) to maximize
78
- recursion_efficiency=1.0, giving up correctness to focus on efficiency bonus.
 
79
 
80
- **Analysis:**
81
- - recursion_efficiency weight = 0.05 (smallest component)
82
- - correctness weight = 0.75
83
- - Even with recursion_efficiency=1.0 and correctness=0.0: composite = min(0.05 * 1.0, 0.05) = 0.05
84
- (A-02 cap applies)
85
- - For correctness=1.0: composite 0.75 + 0.2*tb + 0.05*1.0 ≈ 1.0 — dominates
86
 
87
- **Status:** LOW RISK Efficiency weight is 5% of composite; correctness dominates at 75%.
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.05) |
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 | ✅ LOW RISK | 5% weight; correctness dominates |
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.75
4
- token_budget: 0.2
5
- recursion_efficiency: 0.05
6
  token_budget_variant: "capped_linear"
7
  answer_regex: "<answer>(.*?)</answer>"
8
- max_calls: 2
 
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
- Implements Sequential(Gate(FormatCheck), WeightedSum([Corr*0.75, Tok*0.20, Rec*0.05])).
4
- Format fail composite = 0.0 (no partial credit).
5
-
6
- A-02 fix: if correctness == 0.0, cap composite at 0.05 to prevent
7
- format-only completions with lucky short answers from scoring high.
 
 
 
 
 
 
 
 
 
 
 
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
- ) -> float:
26
- """Single-example composite reward — REW-02.
 
27
 
28
- Gate: format_gate == 0 return 0.0 (no partial credit).
29
- A-02 fix: correctness == 0 → cap at 0.05.
 
30
  """
31
- gate = format_gate(completion)
32
- if gate == 0.0:
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
- # REW-02: weights must sum to 1.0
49
- assert abs(float(w.correctness) + float(w.token_budget) + float(w.recursion_efficiency) - 1.0) < 1e-3, \
50
- "REW-02: composite weights must sum to 1.0"
 
 
 
 
 
 
 
51
 
52
  composite = (
53
  float(w.correctness) * c
54
  + float(w.token_budget) * t
55
- + float(w.recursion_efficiency) * r
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
- return min(composite, 0.05)
62
 
63
- return composite
 
 
 
 
 
 
 
 
64
 
65
 
66
  def compose_reward_fn(prompts: list, completions: list, **kwargs) -> list[float]:
67
- """TRL-compatible batched reward function REW-02.
68
 
69
- Expected kwargs:
70
- gold_answer: list[str]
71
- prompt_token_count: list[int]
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
- lccs = kwargs.get("llm_call_count", [0] * len(completions))
79
- return [
80
- compose_reward_single(c, g, int(p), int(l), cfg_reward)
81
- for c, g, p, l in zip(completions, gold_answers, ptcs, lccs)
82
  ]
 
 
 
 
 
 
 
 
 
83
 
84
 
85
  def make_reward_fn(cfg_reward: Any) -> Callable:
86
- """Factory that binds cfg_reward so GRPOTrainer can call reward_fn(prompts, completions, **kwargs)."""
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-01.
2
 
3
- Discrete staircase on llm_call_count. Pure Python, stdlib-only.
4
- """
5
- _STAIRCASE = {0: 1.0, 1: 0.7, 2: 0.4}
6
 
 
 
 
 
 
 
7
 
8
- def recursion_efficiency(llm_call_count: int, max_calls: int = 2, **_) -> float:
9
- """Staircase bonus for shallow recursion trees.
 
 
10
 
11
- Returns 0.0 if llm_call_count > max_calls (depth-explosion penalty).
12
- """
13
  count = max(0, int(llm_call_count))
14
- if count > max_calls:
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.7
139
 
140
  def test_two_calls(self):
141
- assert recursion_efficiency(2) == 0.4
142
 
143
- def test_three_calls_over_max(self):
144
- assert recursion_efficiency(3) == 0.0
145
 
146
- def test_ten_calls_zero(self):
 
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 test_max_calls_override(self):
153
- # max_calls=3: 3 calls should still score
154
- assert recursion_efficiency(3, max_calls=3) == 0.0 # >2 gets 0 from staircase, but <=max_calls
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, 0, cfg_reward)
166
- assert score == 0.0
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, 0, cfg_reward)
171
- assert score == pytest.approx(0.75 * 1.0 + 0.2 * 1.0 + 0.05 * 1.0, abs=0.05)
172
 
173
- def test_correctness_zero_capped_at_005(self, cfg_reward):
174
- # A-02 fix: format passes but wrong answer → capped at 0.05
175
- score = compose_reward_single("<answer>Paris</answer>", "Rome", 100, 0, cfg_reward)
176
- assert score <= 0.05
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, 0, cfg_reward)
182
- assert 0.5 < score <= 1.0 # correctness=1 dominates
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] == 0.0
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.10 (A-02 cap)."""
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.10, f"Masked-context score too high: {score}"
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.05."""
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, 0, cfg_reward)
235
  for w in words
236
  ]
237
- assert max(composites) <= 0.05, f"Format-only composites: {composites}"
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, 0, cfg_reward)
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
- wandb.log({
143
  "reward/composite_mean": sum(rewards) / max(len(rewards), 1),
144
- "reward/format_pass_mean": float(kwargs.get("_format_pass_mean", 0.0)),
145
- "reward/correctness_mean": float(kwargs.get("_correctness_mean", 0.0)),
146
- "reward/token_budget_mean": float(kwargs.get("_token_budget_mean", 0.0)),
147
- "reward/recursion_eff_mean": float(kwargs.get("_recursion_eff_mean", 0.0)),
148
- })
 
 
 
149
  except Exception:
150
- pass # W&B logging is non-blocking
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