pbl_compiler/scripts/test_m3a_selfcheck.py
2026-09-18 16:17:15 +08:00

271 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_compiler M3a 自检脚本QC #1 整改:真实落盘,可执行)。
运行:
cd modules/pbl_compiler && python3 scripts/test_m3a_selfcheck.py
(或 python3 -m pytest scripts/test_m3a_selfcheck.py -q
覆盖 QC 退回意见要求的断言组(全部纯函数/纯结构,不依赖真实 DB
组1 canonical 确定性:同输入 → canonical_json 字节全等 + sha256 指纹全等US-11/F-CP-03
组2 GD 10 顶层键齐全design 第 9 章)
组3 volatile 字段createdAt/durationMs/taskNo/id...不参与指纹29.6 确定性)
组4 register() 可跑通不崩QC #3格式串占位符错位修复验证
组5 三处同步QC #2__init__ 导出 == init.CONTRACTS == load_path.PATHS 契约名一致
组6 无硬编码 DBQC #4api.py 无 `DB = '...'` 常量_dbname() 走 get_module_dbname
组7 浮点定点 + sort_keys键序无关、浮点 6 位定点canonical 规范化)
组8 registry_hash 确定性:同能力集 → 同 hash增删能力 → hash 变化
退出码 0 = 全部通过;非 0 = 有断言失败(打印 FAIL 明细)。
"""
from __future__ import annotations
import ast
import io
import os
import re
import sys
import types
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE)
sys.path.insert(0, MOD_ROOT) # pbl_compiler 包
sys.path.insert(0, os.path.join(os.path.dirname(MOD_ROOT), 'pbl_common')) # pbl_common 依赖
# ---- ahserver 桩(工作空间无平台运行时;仅为 import init.py 不崩)----
if 'ahserver.serverenv' not in sys.modules:
_ah = types.ModuleType('ahserver')
_se = types.ModuleType('ahserver.serverenv')
class _ServerEnv(object):
_inst = None
def __new__(cls):
if cls._inst is None:
cls._inst = super(_ServerEnv, cls).__new__(cls)
return cls._inst
_se.ServerEnv = _ServerEnv
_ah.serverenv = _se
sys.modules['ahserver'] = _ah
sys.modules['ahserver.serverenv'] = _se
from pbl_compiler import canonical as cn # noqa: E402
from pbl_compiler import gd_builder as gb # noqa: E402
from pbl_compiler import api as api # noqa: E402
from pbl_compiler import init as init_mod # noqa: E402
import pbl_compiler as pkg # noqa: E402
PASS, FAIL = [], []
def check(name, cond, detail=''):
(PASS if cond else FAIL).append(name)
print(' [%s] %s%s' % ('PASS' if cond else 'FAIL', name,
(' <- ' + detail) if (detail and not cond) else ''))
# 一个最小但真实的蓝图版本快照(含 7 类子对象若干 + 易变字段)
def _snapshot():
return {
'content': {
'project': [{'id': 'p1', 'title': '火星基地', 'summary': '建造可持续基地'}],
'problem': [{'id': 'q1', 'driving_question': '如何在火星自给自足?'}],
'learning_goals': [
{'id': 'g2', 'text': '掌握生态循环'},
{'id': 'g1', 'text': '理解能源约束'},
],
'missions': [{'id': 'm1', 'title': '着陆', 'order': 1}],
'roles': [{'id': 'r1', 'name': '工程师'}],
'scenes': [{'id': 's1', 'name': '基地外'}],
'entities': [{'id': 'e1', 'entity_key': 'solar_panel', 'scene_id': 's1'}],
'events': [{'id': 'ev1', 'event_key': 'power_on', 'entity_key': 'solar_panel'}],
'rubrics': [{'id': 'rb1', 'criterion': '可行性', 'weight': 0.5}],
}
}
def _ctx(i=0):
return {'blueprint_id': 101, 'blueprint_version_no': 3,
'compiler_version': '1.0.0', 'ruleset_version': 'pbl.rules.v1',
'rules_hash': 'deadbeefcafe1234',
'created_at': '2000-01-01T00:00:00Z', 'duration_ms': i,
'task_no': 'CT:101:3:1.0.0:%05d' % i}
def group1_canonical_determinism():
print('组1 canonical 确定性(同输入指纹全等)')
obj = {'b': [3, 1, 2], 'a': {'z': 1.5, 'y': 'x'}, 'n': None}
c1 = cn.canonical_json(obj, strip=False)
c2 = cn.canonical_json(obj, strip=False)
check('canonical_json 同输入字节全等', c1 == c2, '%r != %r' % (c1, c2))
f1, f2 = cn.sha256_fingerprint(obj), cn.sha256_fingerprint(obj)
check('sha256_fingerprint 同输入全等', f1 == f2 and len(f1) == 64, f1)
# 键序无关
obj2 = {'a': {'y': 'x', 'z': 1.5}, 'n': None, 'b': [3, 1, 2]}
check('键序不同 canonical 仍全等sort_keys',
cn.canonical_json(obj, strip=False) == cn.canonical_json(obj2, strip=False))
def group2_gd_top_keys():
print('组2 GD 10 顶层键齐全')
gd, fp = gb.build_game_definition(_snapshot(), _ctx(), [])
check('GD_TOP_KEYS 恰为 10 个', len(gb.GD_TOP_KEYS) == 10, str(gb.GD_TOP_KEYS))
missing = [k for k in gb.GD_TOP_KEYS if k not in gd]
check('GD 含全部 10 顶层键', not missing, 'missing=%s' % missing)
check('GD 指纹为 64 位 sha256', isinstance(fp, str) and len(fp) == 64, fp)
check('manifest.schema = pbl.game_definition.v1',
gd.get('manifest', {}).get('schema') == cn.GD_SCHEMA)
def group3_volatile_excluded():
print('组3 volatile 字段不参与指纹')
gd_a, fp_a = gb.build_game_definition(_snapshot(), _ctx(0), [])
gd_b, fp_b = gb.build_game_definition(_snapshot(), _ctx(999), [])
check('created_at/duration_ms/task_no 不同但指纹全等', fp_a == fp_b,
'%s != %s' % (fp_a, fp_b))
# 直接验证 strip_volatile 剔除易变键
dirty = {'createdAt': '2020', 'durationMs': 5, 'taskNo': 'X', 'id': 9,
'stable': 'keep'}
stripped = cn.strip_volatile(dirty)
leaked = [k for k in ('createdAt', 'durationMs', 'taskNo', 'id') if k in stripped]
check('strip_volatile 剔除全部易变键', not leaked and stripped.get('stable') == 'keep',
'leaked=%s stripped=%s' % (leaked, stripped))
for k in ('createdAt', 'created_at', 'durationMs', 'duration_ms',
'taskNo', 'task_no', 'id', 'timestamp'):
if k not in cn.VOLATILE_KEYS:
check('VOLATILE_KEYS 含 %s' % k, False)
return
check('VOLATILE_KEYS 覆盖时间/耗时/任务号/id', True)
def group4_register_runs():
print('组4 register() 可跑通不崩QC #3')
sys.path.insert(0, HERE)
import importlib
lp = importlib.import_module('load_path')
os.environ['RBAC_SET_PERM'] = '/nonexistent/set_role_perm.py' # 强制走 pending 分支
os.environ.pop('PY', None)
try:
ret = lp.register()
crashed = False
err = ''
except Exception as exc: # noqa: BLE001
crashed, ret, err = True, None, '%s: %s' % (type(exc).__name__, exc)
check('register() 不抛 TypeError格式串占位符已对齐', not crashed, err)
check('register() 返回 boolpending 非空 → False', isinstance(ret, bool), repr(ret))
check('PATHS 含 15 个端点', len(lp.PATHS) == 15, str(len(lp.PATHS)))
def group5_three_place_sync():
print('组5 三处同步QC #2')
api_contracts = set(n for n in api.__all__ if n.startswith('pbl_'))
init_contracts = set(init_mod.CONTRACTS.keys())
pkg_exports = set(n for n in pkg.__all__ if n.startswith('pbl_'))
sys.path.insert(0, HERE)
import importlib
lp = importlib.import_module('load_path')
rbac_names = set(os.path.basename(p).replace('.dspy', '') for p, _ in lp.PATHS)
check('init.CONTRACTS == api 契约15 个)', init_contracts == api_contracts,
'only_api=%s only_init=%s' % (api_contracts - init_contracts,
init_contracts - api_contracts))
check('__init__ 导出 == init.CONTRACTS', pkg_exports == init_contracts,
'missing_export=%s' % (init_contracts - pkg_exports))
check('load_path.PATHS 契约名 == init.CONTRACTS', rbac_names == init_contracts,
'missing_rbac=%s extra_rbac=%s' % (init_contracts - rbac_names,
rbac_names - init_contracts))
# QC #2 点名的 8 个此前漏接线契约必须在三处都出现
eight = ['pbl_compiler_task_get', 'pbl_compiler_task_list',
'pbl_game_definition_get', 'pbl_game_definition_get_by_blueprint',
'pbl_compiler_verify_determinism', 'pbl_compiler_version_register',
'pbl_compiler_version_get', 'pbl_compiler_version_diff']
for name in eight:
ok = (name in pkg_exports and name in init_contracts and name in rbac_names
and callable(getattr(pkg, name, None)))
check('QC#2 契约 %s 三处齐全且可调用' % name, ok)
# verify_determinism 是 US-11 验收入口,必须可从包直接取到
check('US-11 入口 pbl_compiler_verify_determinism 可达',
callable(getattr(pkg, 'pbl_compiler_verify_determinism', None)))
def group6_no_hardcoded_db():
print('组6 无硬编码 DBQC #4')
src = io.open(os.path.join(MOD_ROOT, 'pbl_compiler', 'api.py'),
encoding='utf-8').read()
check("api.py 无 `DB = '...'` 常量", re.search(r"(?m)^DB\s*=\s*['\"]", src) is None)
check('api.py 无模块级 DB 属性', not hasattr(api, 'DB'))
check('_dbname() 走 get_module_dbname 解析', callable(api._dbname)
and isinstance(api._dbname(), str) and api._dbname())
# AST 扫描:模块级赋值不得出现 DB = 字面量
tree = ast.parse(src)
bad = []
for node in tree.body:
if isinstance(node, ast.Assign):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == 'DB' \
and isinstance(node.value, ast.Constant) \
and isinstance(node.value.value, str):
bad.append(t.id)
check('AST无模块级 DB = <字符串> 硬编码', not bad, str(bad))
# set_dbname 注入生效(宿主/测试可覆盖,证明非写死)
old = api._dbname()
api.set_dbname('pbl_injected')
injected = api._dbname()
api.set_dbname(None)
check('set_dbname 注入可覆盖库名', injected == 'pbl_injected', injected)
check('set_dbname(None) 回落解析值', api._dbname() == old, api._dbname())
def group7_float_and_sort():
print('组7 浮点定点 + 无空白分隔符')
c = cn.canonical_json({'x': 1.00000049, 'y': 2.5}, strip=False)
check('canonical 无空白分隔符', ' ' not in c and '\n' not in c, c)
check('浮点按 FLOAT_PRECISION 定点', cn.FLOAT_PRECISION == 6)
a = cn.canonical_json({'k': 0.1 + 0.2}, strip=False)
b = cn.canonical_json({'k': 0.3}, strip=False)
check('0.1+0.2 与 0.3 定点后全等', a == b, '%s vs %s' % (a, b))
def group8_registry_hash():
print('组8 registry_hash 确定性')
caps = [{'capability_key': 'b', 'version_no': 1}, {'capability_key': 'a', 'version_no': 2}]
h1 = cn.registry_hash(caps)
h2 = cn.registry_hash(list(reversed(caps)))
check('同能力集顺序无关registry_hash 全等', h1 == h2 and bool(h1), '%s vs %s' % (h1, h2))
caps2 = caps + [{'capability_key': 'c', 'version_no': 1}]
check('新增能力 registry_hash 变化', cn.registry_hash(caps2) != h1)
def main():
print('=' * 64)
print('pbl_compiler M3a 自检QC #1/#2/#3/#4 整改验证)')
print('=' * 64)
for fn in (group1_canonical_determinism, group2_gd_top_keys,
group3_volatile_excluded, group4_register_runs,
group5_three_place_sync, group6_no_hardcoded_db,
group7_float_and_sort, group8_registry_hash):
try:
fn()
except Exception as exc: # noqa: BLE001
import traceback
FAIL.append('%s(异常)' % fn.__name__)
print(' [ERROR] %s: %s' % (fn.__name__, exc))
traceback.print_exc()
print('-' * 64)
print('PASS=%d FAIL=%d' % (len(PASS), len(FAIL)))
if FAIL:
print('FAILED: %s' % FAIL)
return 1
print('ALL GREEN')
return 0
# ---- pytest 兼容 ----
def test_m3a_selfcheck():
assert main() == 0
if __name__ == '__main__':
sys.exit(main())