评测机搭建经验

整体架构设计

评测机的搭建主要分为两部分,一部分是数据生成器,即 data_generator ,一部分为正确性检验器,这里将其命名为 checker

数据生成器接受两个传入的参数,一个是需要生成的数据组数 T,一个是数据生成类型(对应面向对象设计与构造课程的强测与互测两种数据限制)。并生成 T 组对应的数据,传入 data 文件夹内。

正确性检验器调用待测的 jar 文件(一个或多个),传入 data 文件夹中的数据,获得 jar 的输出,并根据输入文件来检查输出的正确性。并根据不同情况给出不同的评测结果,例如 ACWATLERE等。最后将程序的原始输出保存在 out 文件夹下,并将一些评测的详细日志放在 log 文件夹下。

一般还需要一个主控脚本,来统筹调用这些文件,并进行。

整体架构可抽象如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/tester
|
|- testjar/ # 存放待测试的学生 JAR 包
| |- student_A.jar
| |- student_B.jar
|
|- data/ # 存放生成或手写的测试用例
| |- test_case_01.txt
| |- test_case_02.txt
|
|- std/ # (可选) 存放作为“标杆”的标准程序 JAR 包 - 用于对拍测试
| |- std.jar
|
|- out/ # 存放学生程序的标准输出 (stdout)
| |- student_A_test_case_01.txt
|
|- log # 存放评测日志、总结报告或错误详情
| |- student_A_test_case_01_WA.log
|
|- data_generator.py # 数据生成器脚本
|- check.py # 评测机主控脚本&正确性检验
|- test.bat # 评测机主控脚本

评测机具体原理

数据生成器

数据生成规则的设计

数据生成规则是为了能够生成出合法的,有效的数据。一般需要比较复杂的逻辑来模拟题目情景。

例如,在第一单元的第一次表达式展开作业中,指导书给出了非常规范清晰的文法,来定义需要展开的表达式。学生需要使用递归下降等算法,来对语法进行解析。生成数据时,则也需要根据文法规则,来进行相应的合法数据生成。

这里展示一段代码,来展现递归调用的数据生成规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void generate_expr(int depth) {
int rand_num=randbetween(1,2*expected_expr_length);
for(int i=1;i<=rand_num;++i){
generate_sign();
generate_term(depth);
}
}
void generate_sign(){
int rand_num=randbetween(1,100);
if(rand_num<=positive_sign_percentage) fprintf(inptr,"+");
else fprintf(inptr,"-");
}
void generate_term(int depth){
int rand_num=randbetween(1,2*expected_term_length);
for(int i=1;i<rand_num;++i){
generate_factor(depth);
fprintf(inptr,"*");
}
generate_factor(depth);
}
void generate_factor(int depth){
int rand_num=randbetween(1,100);
if (rand_num<=expression_percentage){
if(depth<MAX_DEPTH){ // expression factor
fprintf(inptr,"(");
generate_expr(depth+1);
fprintf(inptr,")^");
generate_normal_integer(3);
}
else{ // if bracket too deep...generate other factor
int rand_num=randbetween(1,pow_percentage+signed_integer_percentage);
if(rand_num<=pow_percentage){ // pow factor
generate_pow();
}
else{
generate_signed_integer(); // integer factor
}
}

}
else if(rand_num<=expression_percentage+signed_integer_percentage) // integer factor
generate_signed_integer();
else generate_pow(); // pow factor
}
...

对于较为复杂的题目,例如2025年OO第三单元的社交网络题目,为了使自己的数据尽量合法,则需要在数据生成器中维护比较复杂的变量池,来记录之前生成的数据和社交网络关系。对于新增关系等操作,则应该从变量池中拿去一个已经注册过的 id。这里的实现其实和解题的代码较为相似,需要专门的设计。

但是,仅仅满足数据合法并不足够,我们需要数据足够强,能测出更多的东西。

数据生成策略的制定

阅读上方例子可以发现,这份代码使用了随机的思想,随机制定每个项的因子数,每个因子随机类型…

这是随机生成策略的一种典型实现。在采用随机的同时,在代码上方预设了很多全局参数,例如 expected_expr_length 表示一个表达式期望由多少个项组成, expression_percentage 表示因子中表达式因子所占的比例。这些参数可以灵活地调整,来优化评测机的表现。

理论上,随机数据可以覆盖所有情况。但是随机出一些极端数据的概率几乎为零。我们需要手动制订数据生成策略来覆盖其他情况。

数据生成策略是为了使生成的数据能够具有更高的强度,对于测试代码达到更高的覆盖率,能测出更多隐藏的 bug。

一个优秀的数据生成器的数据生成策略应当做到随机性与有序性相融合,在整体上制定策略,在局部的细节上采用随机。

设计数据生成策略时,一般从这些这些方面思考制订:

时间复杂度测试策略

这方面的数据不检验综合的实现正确性,而专注于各个方面的时间复杂度检验。一组数据只去测试一个功能,充分测试这个功能的强度。

例如,在互测期间,我曾被一组这样的数据攻击。

sin(sin(sin(sin(sin(sin(sin(sin(sin(sin(sin(sin(sin(sin(sin(sin(sin(sin(x))))))))))))))))))

这个数据敏锐的捕捉到我一个深拷贝的瓶颈,在递归层数过深时,我的深拷贝的时间复杂度会指数级增长。这样的数据,随机策略基本上不可能随机出来。

实际做的时候,对于每一种情况设计较纯的数据,在数据范围内大量的单一轰炸每一种功能,来监测这个功能的实现是否过于复杂。

在表达式的例子中,可以某一数据点将三角函数因子的权重调大,以专门测试三角函数,其他情况同理。

边界覆盖策略

边界覆盖策略即要考虑较为特殊的情况,一般来说,着眼各个参数取特殊值的情况,例如刻意构造 0^0 的情况,或者可以构造极大值相乘得到特别大的数字的情况,以卡掉对于这些情况处理不得当的程序。

以此种边界为例,编写代码时应当在数字随机的基础上,增加数字为 0 和数字为较大值的权重情况,能够生成更多特殊数据。

基本功能覆盖策略

基本的功能覆盖分为一般依靠纯随机实现。参考上文 generate_xxx 的代码,随机时务必要各种情况都要能够随机到。

代码为每种策略单独编写代码后,可以用这样的方法调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def generate_random_data()
...#随机测试
def generate_stress_data()
...#压力测试
def generate_special_data()
...#边界测试
def generate_data(test_cases)
randnum = random.randint(1,3)
if randnum == 1:
generate_random_data()
if randnum == 2:
generate_stress_data()
if randnum == 3:
generate_special_data()

正确性检验器

正确性检验器一般可分为两种情况

对拍器

这是最简单的方法,但是使用场景十分局限。其工作原理是,同一组数据喂给标程 jar 和待测 jar,比较这两者的结果是否相等。

一般采用字符串比较就行了,用于解唯一的情况。但是对于一些解不唯一,但是非常好判断等价的情况,也可以使用对拍的方法。

例如:

1
2
3
4
5
6
7
8
9
def compare_worker(std_expr, test_expr, result_queue):
"""子进程执行符号比较"""
try:
expr1 = expand_trig(simplify(sympify(std_expr)))
expr2 = expand_trig(simplify(sympify(test_expr)))
result_queue.put(('SUCCESS', expr1.equals(expr2)))
except Exception as e:
result_queue.put(('ERROR', str(e)))

此评测机针对 hw2 。由于题目引入了函数因子,无法使用 sympy 直接化简,但是输出却是可以化简的普通表达式。

这里的 std_exprtest_expr 是标程和待测程序的 stdout ,虽然不能直接使用字符串比较,但是对于字符串的比较可以直接用 sympy 等库来简单实现。

基于规则的状态机校验(Special Judge)

对于多线程电梯作业(hw5),输出是一个实时变化的动作序列,没有固定的“标准答案”。此时,校验器本身需要模拟题目情景。

我为此实现了一个 Validator 类,它在内部模拟了一个完整的电梯系统状态机。它维护了每部电梯的位置、状态(开/关门)、载客情况,以及每位乘客的位置和状态。当收到学生程序的每一行输出时(如 ARRIVE, OPEN, IN),Validator 会:

  1. 检查时间戳:是否非递减。
  2. 检查行为合法性:根据当前状态,判断该行为是否被允许。例如,电梯关着门时不能 IN 乘客;电梯移动一层的时间不能小于 0.4s。
  3. 更新内部状态:如果行为合法,则更新内部模拟的状态,为校验下一行输出做准备。
  4. 终局检查:所有输出处理完毕后,检查是否所有乘客都到达了目的地,所有电梯是否都处于关门状态。

这种模式的本质是将问题描述中的所有“正确性约束”代码化

对于 RE 和 TLE 的捕获

对于学生程序的调用,一个最好的方法就是 subprocess 库,以实现对于子进程时间的监控。

这里附上调用 JAR 包的函数代码,一般可以在启动子进程时,在 communicate() 方法中设置 timeout 参数来判断 TLE 问题,而对于 RE 的判断,可以直接查找 stderr 是否为空,或者看 process 的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def run_jar(jar_path, input_path, output_path, timeout): 
status = 'AC'
stderr_content = b''

# 使用 subprocess.Popen 启动进程
process = subprocess.Popen(
['java', '-jar', jar_path],
stdin=infile,
stdout=outfile,
stderr=subprocess.PIPE # 捕获 stderr
)

try:
# communicate 会等待进程结束,并返回 stdout 和 stderr 的内容
_, stderr_content = process.communicate(timeout=timeout)

# 判断 RE 的核心逻辑
if stderr_content: # 1. 检查 stderr 是否有内容
status = 'RE'
elif process.returncode != 0: # 2. 检查退出码是否非零
# 有时程序崩溃但 stderr 为空,也算 RE
status = 'RE'

except subprocess.TimeoutExpired:
# ... 超时处理 ...
status = 'TLE'

return status, b'', stderr_content

评测机底层的基本原理就这么多。不过,这些只是让评测机能用且强大。但不一定好用。一个好的程序,应该让用户用着很舒服,这里提供一些让评测机好用的点。

其他要点

  • 调试信息的支持

多线程作业里,常常遇到这样的情况:在评测机里跑出错误,但是数据本地跑确实对的。

实际上,很多数据本身发错率只有 1% ,这使得本地调试几乎不可能。

一个非常传统的调试手段就是打印中间变量。但是打印的这些行会让没有特殊设计的评测机直接判错。最合理的方法就是代码和评测机约定一个规则,评测机会忽略掉以 [LOG] 字符开头的行,使得评测机跑出的 stdout 即使有调试信息也不影响结果。

  • 文件的一些操作

以我的主控脚本为例,它的具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
:: 询问是否生成数据
set "generate="
set /p "generate=Do you want to generate new test data? [y/n]: "

:: 转换为小写处理
if /i "!generate!"=="y" (
echo Cleaning data folder...
if exist data (
rd /s /q data
)
mkdir data
echo Generating test data...
data_generator.exe
if errorlevel 1 (
echo Data generation failed!
pause
exit /b 1
)
)

:: 运行测试程序
echo Starting evaluation...
python check.py

pause

它会先询问是否使用数据生成器,如果是,则清空文件夹,调用 data_generator。如果不,就采用 data 文件夹下预设的数据。这样做方便使用同学之间交流获得的数据,甚至可以很方便的直接拿同学的数据生成器拼到自己的正确性检验器上,也可以方便的重测之前测出来的错误数据,十分灵活。

  • 评测机多线程

对于含大量 sleep 的代码,单线程的评测 cpu 时间往往是实际运行时间的极少一部分,造成了严重的资源浪费和评测效率下降。使用评测机多线程可以极大地提升评测效率。具体可以用 pythonthreading 库实现,这里不展开讲解。

关于大模型辅助评测机搭建

由于评测机工作量非常大,学会使用大模型辅助评测机搭建非常重要。

使用大模型搭建评测机,最最重要的一步是选用智力足够的大模型,例如 Gemini 2.5 Pro等。大模型的智力永远是最重要的。

其次,大模型的使用有一个很关键的规律:当你让他专心只干一件事时,大模型的效率会比同时干很多事要高。这里的“同时”指的是在同一个窗口中。因此,在搭建评测机时,我往往会开两个窗口,一个让它专心搭建数据生成器,告诉他生成的文件放到哪里就可以。一个让它专心搭正确性检验器,告诉他数据生成器已经做好了, 告诉他直接从 data 文件夹中拿数据就可以。

同时,由于评测机搭建较为困难,往往需要和大模型交流来调 bug 。当大模型的上下文过长时,往往会出现降智的情况,最好隔一段时间,让大模型给自己一个工作交接文档,然后新开一个窗口继续交流。

使用大模型搭建出一个评测机后,需要进行充分测试后使用。这里建议使用两个同学的 jar 文件同时测试,来测试评测机的鲁棒性,防止其仅在自己的代码上表现正确。对于 WA 的数据,一定要分析是自己确实 WA ,还是评测机有误,在迭代中加强评测机。