diff --git a/README.md b/README.md
index 2711df5..d4b616b 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +1,7 @@
-
-
-[](https://github.com/jingyaogong/minimind/stargazers)
-[](LICENSE)
-[](https://github.com/jingyaogong/minimind/commits/master)
-[](https://github.com/jingyaogong/minimind/pulls)
-[](https://huggingface.co/collections/jingyaogong/minimind-66caf8d999f5c7fa64f399e5)
-
-
-
-
-
-
-[](https://github.com/jingyaogong/minimind/stargazers)
-[](LICENSE)
-[](https://github.com/jingyaogong/minimind/commits/master)
-[](https://github.com/jingyaogong/minimind/pulls)
-[](https://huggingface.co/collections/jingyaogong/minimind-66caf8d999f5c7fa64f399e5)
-
-
-
-'
'
'
- f'

'
f'
{slogan}'
'
'
f'
{get_text("disclaimer")}'
@@ -348,7 +345,7 @@ def main():
setup_seed(random_seed)
tools = [t for t in TOOLS if t['function']['name'] in st.session_state.get('selected_tools', [])] or None
- sys_prompt = [] if tools else [{"role": "system", "content": "你是MiniMind,一个乐于助人、知识渊博的AI助手。请用完整且友好的方式回答用户问题。"}]
+ sys_prompt = [] if tools else [{"role": "system", "content": "你是一个用于轻量语言模型实验的AI助手。请准确、完整且友好地回答用户问题,不要虚构开发者或个人身份。"}]
st.session_state.chat_messages = sys_prompt + st.session_state.chat_messages[-(st.session_state.history_chat_num + 1):]
template_kwargs = {"tokenize": False, "add_generation_prompt": True}
if st.session_state.get('enable_thinking', False):
diff --git a/tests/fixtures/code_predictions.jsonl b/tests/fixtures/code_predictions.jsonl
new file mode 100644
index 0000000..2b5c7e0
--- /dev/null
+++ b/tests/fixtures/code_predictions.jsonl
@@ -0,0 +1 @@
+{"task_id":"add-two","candidates":["def add(a, b):\n return a + b","def add(a, b):\n return a - b"]}
diff --git a/tests/fixtures/code_rl_predictions.jsonl b/tests/fixtures/code_rl_predictions.jsonl
new file mode 100644
index 0000000..61267d1
--- /dev/null
+++ b/tests/fixtures/code_rl_predictions.jsonl
@@ -0,0 +1,4 @@
+{"task_id":"reverse-string","candidates":["def reverse_string(text):\n return text[::-1]","def reverse_string(text):\n return text"]}
+{"task_id":"valid-parentheses","candidates":["def valid_parentheses(text):\n pairs = {')': '(', ']': '[', '}': '{'}\n stack = []\n for char in text:\n if char in '([{':\n stack.append(char)\n elif not stack or stack.pop() != pairs[char]:\n return False\n return not stack","def valid_parentheses(text):\n return True"]}
+{"task_id":"binary-search","candidates":["def binary_search(values, target):\n left, right = 0, len(values) - 1\n while left <= right:\n mid = (left + right) // 2\n if values[mid] == target:\n return mid\n if values[mid] < target:\n left = mid + 1\n else:\n right = mid - 1\n return -1","def binary_search(values, target):\n return -1"]}
+{"task_id":"max-subarray","candidates":["def max_subarray(values):\n best = current = values[0]\n for value in values[1:]:\n current = max(value, current + value)\n best = max(best, current)\n return best","def max_subarray(values):\n return sum(values)"]}
diff --git a/tests/fixtures/code_tasks.jsonl b/tests/fixtures/code_tasks.jsonl
new file mode 100644
index 0000000..e3c7461
--- /dev/null
+++ b/tests/fixtures/code_tasks.jsonl
@@ -0,0 +1 @@
+{"task_id":"add-two","prompt":"Implement add(a, b) and return the sum.","entry_point":"add","tests":[{"args":[1,2],"expected":3},{"args":[-5,5],"expected":0}]}
diff --git a/tests/test_code_agent.py b/tests/test_code_agent.py
new file mode 100644
index 0000000..5a815ce
--- /dev/null
+++ b/tests/test_code_agent.py
@@ -0,0 +1,47 @@
+from code_agent import CodeTask, ExecutionFeedbackAgent, TestCase as CodeTestCase
+
+
+TASK = CodeTask(
+ task_id="maximum",
+ prompt="Implement maximum(values), returning the largest integer.",
+ entry_point="maximum",
+ tests=(
+ CodeTestCase(args=([1, 9, 3],), expected=9),
+ CodeTestCase(args=([-5, -2, -8],), expected=-2),
+ ),
+)
+
+
+class ScriptedGenerator:
+ def __init__(self, responses):
+ self.responses = iter(responses)
+ self.prompts = []
+
+ def __call__(self, prompt):
+ self.prompts.append(prompt)
+ return next(self.responses)
+
+
+def test_agent_repairs_solution_from_hidden_test_feedback():
+ generator = ScriptedGenerator(
+ [
+ "```python\ndef maximum(values):\n return values[0]\n```",
+ "```python\ndef maximum(values):\n return max(values)\n```",
+ ]
+ )
+ result = ExecutionFeedbackAgent(generator, max_attempts=3).run(TASK)
+
+ assert result.success
+ assert len(result.attempts) == 2
+ assert result.attempts[0].verification.pass_rate == 0.0
+ assert "hidden test failed" in generator.prompts[1]
+ assert "expected 9" not in generator.prompts[1]
+ assert result.final.reward.total == 1.0
+
+
+def test_agent_stops_at_attempt_limit():
+ generator = ScriptedGenerator(["def maximum(values):\n return 0"] * 2)
+ result = ExecutionFeedbackAgent(generator, max_attempts=2).run(TASK)
+
+ assert not result.success
+ assert len(result.attempts) == 2
diff --git a/tests/test_code_backend.py b/tests/test_code_backend.py
new file mode 100644
index 0000000..3ccacfe
--- /dev/null
+++ b/tests/test_code_backend.py
@@ -0,0 +1,42 @@
+from types import SimpleNamespace
+
+import pytest
+
+from code_agent import OpenAICompatibleGenerator
+
+
+class FakeCompletions:
+ def __init__(self, content):
+ self.content = content
+ self.kwargs = None
+
+ def create(self, **kwargs):
+ self.kwargs = kwargs
+ message = SimpleNamespace(content=self.content)
+ return SimpleNamespace(choices=[SimpleNamespace(message=message)])
+
+
+def test_openai_backend_builds_non_streaming_request():
+ completions = FakeCompletions("```python\ndef solve():\n return 1\n```")
+ client = SimpleNamespace(chat=SimpleNamespace(completions=completions))
+ generator = OpenAICompatibleGenerator(
+ client=client,
+ model="local-minimind",
+ temperature=0.2,
+ max_tokens=128,
+ open_thinking=True,
+ )
+
+ assert generator("solve it").startswith("```python")
+ assert completions.kwargs["messages"][0]["content"] == "solve it"
+ assert completions.kwargs["stream"] is False
+ assert completions.kwargs["extra_body"] == {"open_thinking": True}
+
+
+def test_openai_backend_rejects_empty_completion():
+ completions = FakeCompletions("")
+ client = SimpleNamespace(chat=SimpleNamespace(completions=completions))
+ generator = OpenAICompatibleGenerator(client=client)
+
+ with pytest.raises(RuntimeError, match="empty completion"):
+ generator("solve it")
diff --git a/tests/test_code_evaluation.py b/tests/test_code_evaluation.py
new file mode 100644
index 0000000..9901ee4
--- /dev/null
+++ b/tests/test_code_evaluation.py
@@ -0,0 +1,54 @@
+import pytest
+
+from code_agent import (
+ CodeTask,
+ TestCase as CodeTestCase,
+ VerificationStatus,
+ aggregate_reports,
+ estimate_pass_at_k,
+ evaluate_candidates,
+)
+
+
+TASK = CodeTask(
+ task_id="square",
+ prompt="Implement square(x).",
+ entry_point="square",
+ tests=(
+ CodeTestCase(args=(2,), expected=4),
+ CodeTestCase(args=(-3,), expected=9),
+ ),
+)
+
+
+def test_pass_at_k_estimator():
+ assert estimate_pass_at_k(10, 0, 1) == 0.0
+ assert estimate_pass_at_k(10, 1, 10) == 1.0
+ assert estimate_pass_at_k(10, 2, 1) == pytest.approx(0.2)
+ with pytest.raises(ValueError):
+ estimate_pass_at_k(2, 1, 3)
+
+
+def test_evaluation_reports_execution_metrics():
+ candidates = [
+ "def square(x):\n return x * x",
+ "def square(x):\n return x + x",
+ "def square(x)\n return x * x",
+ ]
+ report = evaluate_candidates(TASK, candidates, ks=(1, 3))
+
+ assert report.correct_count == 1
+ assert report.compile_rate == pytest.approx(2 / 3)
+ assert report.execution_rate == pytest.approx(2 / 3)
+ assert report.test_pass_rate == pytest.approx(0.5)
+ assert report.pass_at_k["pass@1"] == pytest.approx(1 / 3)
+ assert report.pass_at_k["pass@3"] == 1.0
+ assert report.results[2].status == VerificationStatus.SYNTAX_ERROR
+
+ summary = aggregate_reports([report])
+ assert summary["candidate_count"] == 3
+ assert summary["status_counts"] == {
+ "passed": 1,
+ "syntax_error": 1,
+ "wrong_answer": 1,
+ }
diff --git a/tests/test_code_reward_batch.py b/tests/test_code_reward_batch.py
new file mode 100644
index 0000000..798e833
--- /dev/null
+++ b/tests/test_code_reward_batch.py
@@ -0,0 +1,25 @@
+import pytest
+
+from code_agent import CodeTask, TestCase as CodeTestCase, score_code_batch
+
+
+TASK = CodeTask(
+ task_id="identity",
+ prompt="Implement identity(x).",
+ entry_point="identity",
+ tests=(CodeTestCase(args=(7,), expected=7),),
+)
+
+
+def test_scores_task_major_grouped_responses():
+ rewards = score_code_batch(
+ [TASK],
+ ["def identity(x):\n return x", "def identity(x):\n return 0"],
+ num_generations=2,
+ )
+ assert [reward.total for reward in rewards] == [1.0, 0.0]
+
+
+def test_rejects_misaligned_group_size():
+ with pytest.raises(ValueError, match="expected 2 responses"):
+ score_code_batch([TASK], ["def identity(x): return x"], num_generations=2)
diff --git a/tests/test_code_rl_dataset.py b/tests/test_code_rl_dataset.py
new file mode 100644
index 0000000..9c19ab8
--- /dev/null
+++ b/tests/test_code_rl_dataset.py
@@ -0,0 +1,31 @@
+import json
+
+from code_agent import CodeRLDataset, collate_code_rl
+
+
+class FakeTokenizer:
+ def apply_chat_template(self, messages, **kwargs):
+ return messages[0]["content"] + f"\nthinking={kwargs['open_thinking']}"
+
+
+def test_code_rl_dataset_builds_prompt_and_preserves_tests(tmp_path):
+ path = tmp_path / "tasks.jsonl"
+ path.write_text(
+ json.dumps(
+ {
+ "task_id": "inc",
+ "prompt": "Implement inc(x).",
+ "entry_point": "inc",
+ "tests": [{"args": [1], "expected": 2}],
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ dataset = CodeRLDataset(path, FakeTokenizer(), thinking_ratio=0.0)
+ sample = dataset[0]
+
+ assert "Required entry point: inc" in sample["prompt"]
+ assert sample["task"].tests[0].expected == 2
+ batch = collate_code_rl([sample])
+ assert batch["tasks"][0].task_id == "inc"
diff --git a/tests/test_code_verifier.py b/tests/test_code_verifier.py
new file mode 100644
index 0000000..c7a7efa
--- /dev/null
+++ b/tests/test_code_verifier.py
@@ -0,0 +1,57 @@
+from code_agent import (
+ CodeTask,
+ TestCase as CodeTestCase,
+ VerificationStatus,
+ extract_python_code,
+ score_verification,
+ verify_code,
+)
+
+
+TASK = CodeTask(
+ task_id="add-two",
+ prompt="Implement add(a, b).",
+ entry_point="add",
+ tests=(
+ CodeTestCase(args=(1, 2), expected=3),
+ CodeTestCase(args=(5, 0), expected=5),
+ ),
+)
+
+
+def test_extracts_fenced_code_after_thinking():
+ response = "
reasoning\n```python\ndef add(a, b):\n return a + b\n```"
+ assert extract_python_code(response).startswith("def add")
+
+
+def test_verifier_accepts_correct_solution():
+ result = verify_code("def add(a, b):\n return a + b", TASK)
+ assert result.status == VerificationStatus.PASSED
+ assert result.pass_rate == 1.0
+ assert score_verification(result).total == 1.0
+
+
+def test_verifier_reports_partial_credit_and_feedback():
+ result = verify_code("def add(a, b):\n return a - b", TASK)
+ assert result.status == VerificationStatus.WRONG_ANSWER
+ assert result.passed_tests == 1
+ assert "expected 3, got -1" in result.feedback
+ assert score_verification(result).total == 0.4
+
+
+def test_verifier_reports_syntax_error():
+ result = verify_code("def add(a, b)\n return a + b", TASK)
+ assert result.status == VerificationStatus.SYNTAX_ERROR
+ assert score_verification(result).total == -0.8
+
+
+def test_verifier_blocks_unsafe_import():
+ result = verify_code("import os\ndef add(a, b):\n return a + b", TASK)
+ assert result.status == VerificationStatus.POLICY_VIOLATION
+ assert "import 'os' is not allowed" in result.feedback
+
+
+def test_verifier_times_out_infinite_loop():
+ result = verify_code("def add(a, b):\n while True:\n pass", TASK, timeout_seconds=0.2)
+ assert result.status == VerificationStatus.TIMEOUT
+ assert score_verification(result).total == -0.6
diff --git a/trainer/train_grpo.py b/trainer/train_grpo.py
index 200adb6..f4de0a7 100755
--- a/trainer/train_grpo.py
+++ b/trainer/train_grpo.py
@@ -24,6 +24,7 @@ from model.model_minimind import MiniMindConfig, MiniMindForCausalLM
from dataset.lm_dataset import RLAIFDataset
from trainer.trainer_utils import Logger, is_main_process, lm_checkpoint, init_distributed_mode, setup_seed, SkipBatchSampler, init_model, LMForRewardModel
from trainer.rollout_engine import create_rollout_engine
+from code_agent import CodeRLDataset, collate_code_rl, score_code_batch
warnings.filterwarnings('ignore')
@@ -34,7 +35,17 @@ def rep_penalty(text, n=3, cap=0.5):
return min(cap, (len(grams) - len(set(grams))) * cap * 2 / len(grams)) if grams else 0.0
-def calculate_rewards(prompts, responses, reward_model):
+def calculate_rewards(prompts, responses, reward_model, tasks=None):
+ if args.reward_type == "code":
+ code_rewards = score_code_batch(
+ tasks,
+ responses,
+ num_generations=args.num_generations,
+ timeout_seconds=args.code_timeout,
+ memory_mb=args.code_memory_mb,
+ )
+ return torch.tensor([reward.total for reward in code_rewards], device=args.device)
+
rewards = torch.zeros(len(responses), device=args.device)
with torch.no_grad():
@@ -92,7 +103,7 @@ def grpo_train_epoch(epoch, loader, iters, rollout_engine, ref_model, reward_mod
full_mask = (outputs != tokenizer.pad_token_id).long()
logp_pos = prompt_lens.unsqueeze(1) - 1 + torch.arange(completion_ids.size(1), device=args.device).unsqueeze(0)
- rewards = calculate_rewards(prompts, completions, reward_model).to(args.device) # [B*num_gen]
+ rewards = calculate_rewards(prompts, completions, reward_model, batch.get('tasks')).to(args.device) # [B*num_gen]
model_unwrapped = model.module if isinstance(model, DistributedDataParallel) else model
with autocast_ctx:
@@ -119,6 +130,7 @@ def grpo_train_epoch(epoch, loader, iters, rollout_engine, ref_model, reward_mod
Logger('='*100)
grouped_rewards = rewards.view(-1, args.num_generations) # [B, num_gen]
+ degenerate_group_rate = (grouped_rewards.std(dim=1, unbiased=False) < 1e-6).float().mean()
mean_r = grouped_rewards.mean(dim=1).repeat_interleave(args.num_generations) # [B*num_gen]
std_r = grouped_rewards.std(dim=1, unbiased=False).repeat_interleave(args.num_generations) # [B*num_gen]
advantages = (rewards - mean_r) / (std_r + 1e-4) # [B*num_gen]
@@ -163,6 +175,7 @@ def grpo_train_epoch(epoch, loader, iters, rollout_engine, ref_model, reward_mod
Logger(f'Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), '
f'Reward: {avg_reward_val:.4f}, KL_ref: {kl_ref_val:.4f}, '
+ f'Degenerate Groups: {degenerate_group_rate.item():.4f}, '
f'Adv Std: {advantages_std_val:.4f}, Adv Mean: {advantages_mean_val:.4f}, '
f'Actor Loss: {policy_loss_val:.4f}, Avg Response Len: {avg_len_val:.2f}, Learning Rate: {current_lr:.8f}')
@@ -174,6 +187,7 @@ def grpo_train_epoch(epoch, loader, iters, rollout_engine, ref_model, reward_mod
"advantages_mean": advantages_mean_val,
"policy_loss": policy_loss_val,
"avg_response_len": avg_len_val,
+ "degenerate_group_rate": degenerate_group_rate.item(),
"learning_rate": current_lr
})
@@ -230,6 +244,9 @@ if __name__ == "__main__":
parser.add_argument("--epsilon_high", type=float, default=5.0, help="epsilon上界")
parser.add_argument('--from_weight', default='full_sft', type=str, help="基于哪个权重训练")
parser.add_argument("--reward_model_path", type=str, default="../../internlm2-1_8b-reward", help="Reward模型路径")
+ parser.add_argument("--reward_type", type=str, default="reward_model", choices=["reward_model", "code"], help="Reward source")
+ parser.add_argument("--code_timeout", type=float, default=2.0, help="Code execution timeout per candidate")
+ parser.add_argument("--code_memory_mb", type=int, default=256, help="POSIX memory limit for each code worker")
parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
parser.add_argument("--wandb_project", type=str, default="MiniMind-GRPO", help="wandb项目名")
@@ -276,7 +293,10 @@ if __name__ == "__main__":
ref_model, _ = init_model(lm_config, base_weight, device=args.device)
ref_model = ref_model.eval().requires_grad_(False)
# Reward模型
- reward_model = LMForRewardModel(args.reward_model_path, device=args.device, dtype=torch.float16)
+ reward_model = (
+ LMForRewardModel(args.reward_model_path, device=args.device, dtype=torch.float16)
+ if args.reward_type == "reward_model" else None
+ )
# Rollout引擎(可插拔替换,只负责 policy 推理)
rollout_engine = create_rollout_engine(
engine_type=args.rollout_engine,
@@ -289,7 +309,11 @@ if __name__ == "__main__":
sglang_shared_path=args.sglang_shared_path,
)
# 数据和优化器
- train_ds = RLAIFDataset(args.data_path, tokenizer, max_length=lm_config.max_seq_len, thinking_ratio=args.thinking_ratio)
+ train_ds = (
+ CodeRLDataset(args.data_path, tokenizer, thinking_ratio=args.thinking_ratio)
+ if args.reward_type == "code"
+ else RLAIFDataset(args.data_path, tokenizer, max_length=lm_config.max_seq_len, thinking_ratio=args.thinking_ratio)
+ )
train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
loader_for_count = DataLoader(train_ds, batch_size=args.batch_size, sampler=train_sampler)
@@ -321,7 +345,8 @@ if __name__ == "__main__":
setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
skip = start_step if (epoch == start_epoch and start_step > 0) else 0
batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
- loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True)
+ collate_fn = collate_code_rl if args.reward_type == "code" else None
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True, collate_fn=collate_fn)
if skip > 0:
Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
grpo_train_epoch(epoch, loader, len(loader) + skip, rollout_engine, ref_model, reward_model, start_step, wandb, use_sglang = (args.rollout_engine == "sglang"))
@@ -331,4 +356,4 @@ if __name__ == "__main__":
# ========== 9. 清理分布进程 ==========
if dist.is_initialized():
dist.barrier()
- dist.destroy_process_group()
\ No newline at end of file
+ dist.destroy_process_group()