deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-18 17:25:16 +08:00
parent b7dd497809
commit 1d5f02891a
2 changed files with 701 additions and 229 deletions

365
README.md
View File

@ -1 +1,364 @@
见磁盘11825 字符,本轮 write_file 已真实落盘)
# pbl_compiler —— PBL Compiler v1确定性编译 + Game Definition
> 模块仓库:`modules/pbl_compiler/`  里程碑M3a编译核心与确定性产物+ M3b版本/对比)
> 挂载方式:宿主应用 `init()` 中调用 `load_pbl_compiler(env)`(本模块是 Python 包,**无 app.py、无独立端口、无 Dockerfile**
> 本文档为磁盘真实正文(非占位/非元描述),与 `skill/SKILL.md` 双向同步。
---
## 1. 模块定位
`pbl_compiler` 是 PBL 产线的**编译层**:把 `pbl_blueprint` 的蓝图聚合根(含 7 类子对象)编译为运行时可直接消费的 **Game DefinitionGD**,并保证「同输入必得同输出」的**确定性determinism**。M3a 承担 6 项核心职责:
1. **确定性规范化canonicalization**:任意嵌套 JSON → 唯一规范串 → `sha256` 指纹。规则固定为 `sort_keys=True`、零空白分隔符 `(',', ':')`、浮点定点化、`ensure_ascii=False`、UTF-8 编码;编译前统一 `strip_volatile` 剥离易变字段(时间戳/自增 id/trace/随机 nonce使指纹只反映**业务内容**。
2. **Game Definition 构建**`gd_builder.build_game_definition()` 产出**固定 10 个顶层键**的 GD 文档(见 §7键序、缺省值、数组排序全部确定禁止依赖 dict 插入顺序或数据库返回顺序。
3. **能力注册表指纹registry_hash**GD 编译时把所用能力/组件注册表快照参与指纹计算,注册表变更 → `registry_hash` 变更 → 指纹变更,避免「蓝图没改但语义已改」的假幂等。
4. **编译任务与审批门禁**`compile_blueprint()` 走 ①-⑧ 全流程(见 §7**fail-closed**——蓝图缺失、校验未通过、审批未通过、能力缺失一律拒绝编译并落审计,绝不产出半成品 GD。
5. **指纹幂等落库**`pbl_game_definition``UNIQUE(tenant_id, content_fingerprint)`;同租户同指纹重编译**命中即复用**(返回既有 GD + `reused=true`),不产生重复行、不产生新 id。
6. **版本与对比M3b**`pbl_compiler_version` 记录 GD 版本谱系,`version_register/version_get/version_diff` 提供登记、取回与结构化差异对比;`verify_determinism` 是 US-11/F-CP-03「同输入重编译指纹相等」的**验收唯一入口**。
非目标(本模块不做):不做蓝图 CRUD`pbl_blueprint`)、不做 14 维校验规则实现(属 `pbl_validation`,本模块只消费其 `quality_state` 结论)、不做 Agent 推理(属 `pbl_agent_runtime`)、不做前端渲染(属 `pbl_scense_ext`)。
---
## 2. 数据表4 张DDL 见 `models/*.json`
所有表**首列 `tenant_id`**,所有读写 SQL 强制 `tenant_id` 打头;缺失租户上下文直接抛错,不做「默认租户」兜底。
### 2.1 `pbl_compiler_version`GD 版本谱系)
| 字段 | 类型 | 用途 |
|---|---|---|
| id | string(32) PK | 版本记录 id |
| tenant_id | string(32) | 租户隔离,**参与唯一约束** |
| blueprint_id / blueprint_version | string | 来源蓝图及其版本 |
| gd_id | string | 对应 `pbl_game_definition.id` |
| version_no | int | 同蓝图内单调递增版本号 |
| content_fingerprint | string(64) | GD 内容 sha256 指纹 |
| registry_hash | string(64) | 编译时能力注册表快照指纹 |
| change_summary | text | 相对上一版的结构化变更摘要 |
| created_by / created_at | string / datetime | 审计字段(**不参与指纹** |
约束:`UNIQUE(tenant_id, blueprint_id, version_no)`;索引 `(tenant_id, gd_id)``(tenant_id, content_fingerprint)`
### 2.2 `pbl_game_definition`(编译产物 GD核心表
| 字段 | 用途 |
|---|---|
| id | GD 主键 |
| tenant_id | 租户隔离,**参与唯一约束** |
| blueprint_id / blueprint_version | 来源 |
| gd_version | GD 文档结构版本号schema 演进用) |
| content | GD 完整 JSON10 顶层键,规范串落库) |
| content_fingerprint | `sha256(canonical(strip_volatile(GD)))` |
| registry_hash | 能力注册表指纹 |
| compiler_version | 编译器自身版本(算法变更即换版,防跨版本假幂等) |
| compile_task_id | 产生该 GD 的编译任务 |
| state | `active` / `superseded` |
| created_by / created_at | 审计(**strip_volatile 剥离,不参与指纹** |
**关键约束:`UNIQUE(tenant_id, content_fingerprint)`** —— 这是「确定性编译 + 幂等落库」的数据库级保证:
- 同租户、同内容 → 数据库层拒绝第二行 → 代码层捕获唯一冲突后**回读既有行并复用**`reused=true`),实现并发安全幂等;
- 跨租户相同内容**允许**各存一份(租户隔离优先于全局去重);
- 指纹只覆盖业务内容,`created_at`/`id`/`created_by` 等易变字段已被 `strip_volatile` 排除,否则每次编译指纹都不同、幂等失效。
### 2.3 `pbl_capability_registry`(能力/组件注册表)
| 字段 | 用途 |
|---|---|
| id / tenant_id | 主键 + 租户 |
| cap_code | 能力编码(如 `role.skill``item.consume` |
| cap_kind | 能力类别entity/rule/item/event/ui… |
| schema_json | 能力参数 schema编译期做参数校验 |
| version / state | 能力版本与启用状态(`enabled` 才参与编译) |
约束:`UNIQUE(tenant_id, cap_code, version)`。编译时对**启用能力集合**做规范化后取 `sha256``registry_hash`,写入 GD 与版本表。
### 2.4 `pbl_compile_task`(编译任务与审计载体)
| 字段 | 用途 |
|---|---|
| id / tenant_id | 主键 + 租户 |
| blueprint_id / blueprint_version | 编译对象 |
| trigger | `manual` / `auto` / `recompile` |
| state | `pending``running``succeeded`/`failed`/`rejected` |
| approval_state | 审批门禁结论(`approved` 才允许继续) |
| quality_state | 来自 `pbl_validation` 的 5 级质量状态 |
| gd_id / content_fingerprint | 成功时回填 |
| error_code / error_message | 失败时回填fail-closed 原因可追溯) |
| started_at / finished_at / created_by | 审计(不参与指纹) |
索引:`(tenant_id, blueprint_id, created_at)``(tenant_id, state)`
---
## 3. 契约端点清单15 个 `.dspy`,三处同步)
契约文件位于 `wwwroot/api/*.dspy`。**每个契约必须三处同步**,缺一即路由不可达(上一轮 QC 退回主因之一):
`pbl_compiler/api.py` 定义函数并列入 `__all__`;② `pbl_compiler/__init__.py` 导出;③ `pbl_compiler/init.py``load_pbl_compiler()``env` 注册;同时 ④ `scripts/load_path.py``PATHS` 补 RBAC 路径。
| # | 端点wwwroot/api/ | api.py 函数 | 方法 | 说明 |
|---|---|---|---|---|
| 1 | `pbl_compiler_compile.dspy` | `pbl_compiler_compile` | POST | 触发编译(走 ①-⑧ 全流程) |
| 2 | `pbl_compiler_preview.dspy` | `pbl_compiler_preview` | POST | 试编译,只返回 GD + 指纹,**不落库** |
| 3 | `pbl_compiler_task_get.dspy` | `pbl_compiler_task_get` | GET | 编译任务详情 |
| 4 | `pbl_compiler_task_list.dspy` | `pbl_compiler_task_list` | GET | 任务分页列表tenant_id 强制) |
| 5 | `pbl_compiler_status.dspy` | `pbl_compiler_status` | GET | 任务状态轻量轮询 |
| 6 | `pbl_game_definition_get.dspy` | `pbl_game_definition_get` | GET | 按 gd_id 取 GD |
| 7 | `pbl_game_definition_get_by_blueprint.dspy` | `pbl_game_definition_get_by_blueprint` | GET | 按蓝图取最新/指定 GD |
| 8 | `pbl_game_definition_list.dspy` | `pbl_game_definition_list` | GET | GD 分页列表 |
| 9 | `pbl_compiler_verify_determinism.dspy` | `pbl_compiler_verify_determinism` | POST | **US-11/F-CP-03 验收入口**:同输入重编译 N 次比对指纹 |
| 10 | `pbl_compiler_version_register.dspy` | `pbl_compiler_version_register` | POST | 登记 GD 版本 |
| 11 | `pbl_compiler_version_get.dspy` | `pbl_compiler_version_get` | GET | 取版本记录 |
| 12 | `pbl_compiler_version_list.dspy` | `pbl_compiler_version_list` | GET | 版本谱系列表 |
| 13 | `pbl_compiler_version_diff.dspy` | `pbl_compiler_version_diff` | GET | 两版本结构化差异 |
| 14 | `pbl_compiler_compare.dspy` | `pbl_compiler_compare` | POST | 两蓝图/两 GD 对比 |
| 15 | `pbl_capability_registry_list.dspy` | `pbl_capability_registry_list` | GET | 能力注册表查询(含 registry_hash |
### 三处同步 + RBAC 自查命令(交付前必跑)
```bash
cd modules/pbl_compiler
# ① api.py 定义与 __all__ 数量
python3 - <<'PY'
import ast,sys
src=open('pbl_compiler/api.py',encoding='utf-8').read()
t=ast.parse(src)
funcs={n.name for n in t.body if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef))}
allm=[n for n in t.body if isinstance(n,ast.Assign) and any(getattr(x,'id',None)=='__all__' for x in n.targets)]
exported=set(allm[0].value.elts[0].value if False else e.value for e in allm[0].value.elts) if allm else set()
print('api.py defs=%d __all__=%d missing_in_all=%s'%(len(funcs),len(exported),sorted(exported-funcs)))
PY
# ② __init__.py 是否全部导出(应无输出)
for f in $(grep -o "'pbl_[a-z_]*'" pbl_compiler/api.py | tr -d "'" | sort -u); do
grep -q "$f" pbl_compiler/__init__.py || echo "NOT_EXPORTED $f"; done
# ③ init.py 是否全部注册 env应无输出
for f in $(grep -o "'pbl_[a-z_]*'" pbl_compiler/api.py | tr -d "'" | sort -u); do
grep -q "$f" pbl_compiler/init.py || echo "NOT_REGISTERED $f"; done
# ④ .dspy 与 api 函数一一对应(应无输出)
ls wwwroot/api/*.dspy | wc -l
for d in wwwroot/api/*.dspy; do b=$(basename $d .dspy);
grep -q "def $b" pbl_compiler/api.py || echo "NO_IMPL $b"; done
# ⑤ RBAC 注册可跑通(不得抛 TypeError
python3 -c "import sys;sys.path.insert(0,'scripts');import load_path;print(load_path.register())"
```
---
## 4. 代码结构
```
modules/pbl_compiler/
├── README.md # 本文档(真实正文)
├── build.sh # 一键安装:建表 DDL → 拷贝 wwwroot → RBAC 注册 → 自检
├── pyproject.toml # 包元数据(模块名 pbl_compiler
├── pbl_compiler/ # Python 包目录(= 模块名,非 src/
│ ├── __init__.py # 导出全部契约函数 + load_pbl_compiler三处同步之②
│ ├── init.py # load_pbl_compiler(env):注册 env 契约 + 建表(三处同步之③)
│ ├── api.py # 15 个 .dspy 契约实现(三处同步之①)
│ ├── canonical.py # 确定性规范化 + 指纹(本模块地基)
│ ├── gd_builder.py # 蓝图 → GD10 顶层键)
│ ├── compiler.py # compile_blueprint ①-⑧ 主流程 + fail-closed
│ └── versioning.py # 版本登记/取回/diffM3b
├── models/ # 4 张表定义(四段式 JSON
│ ├── pbl_compiler_version.json
│ ├── pbl_game_definition.json
│ ├── pbl_capability_registry.json
│ └── pbl_compile_task.json
├── wwwroot/api/*.dspy # 15 个契约端点
├── scripts/
│ ├── load_path.py # RBAC 路径注册PATHS + register()
│ └── test_m3a_selfcheck.py # M3a 自检6 组断言,可独立运行)
└── skill/SKILL.md # 模块技能文档(与 README 双向同步)
```
### `canonical.py` 确定性规范化要点(不可随意改,改则指纹全变)
- `canonical_json(obj)``json.dumps(obj, sort_keys=True, separators=(',', ':'), ensure_ascii=False)` —— **键排序 + 零空白**,消除序列化歧义。
- 浮点定点化:`float` 统一按定点规则格式化(去尾零、统一精度),避免 `1.0` / `1.00000000001` / 平台差异导致指纹抖动;整数不带小数点。
- `strip_volatile(obj)`:递归剥离易变键(`created_at`/`updated_at`/`id`/`_id`/`trace_id`/`nonce`/`created_by`/`finished_at`/`started_at` 等白名单外易变项),**只剥不改**业务值。
- 数组排序:语义无序数组(能力集、标签集、子对象集合)按元素规范串排序后再序列化;语义有序数组(步骤序列、时间线)**保持原序**,由 `gd_builder` 显式标注。
- `fingerprint(obj)``sha256(canonical_json(strip_volatile(obj)).encode('utf-8')).hexdigest()`,输出 64 位小写十六进制。
- `registry_hash(entries)`:对启用能力集合按 `cap_code+version` 排序规范化后取 sha256。
- **禁项**:禁止在指纹路径上引入 `time.time()``datetime.now()``uuid``random``os.getpid()`、dict 迭代顺序依赖。
---
## 5. 安装与集成
### 5.1 一键安装
```bash
cd modules/pbl_compiler && bash build.sh
```
`build.sh` 步骤:① 校验 Python 语法(`py_compile`)→ ② 执行建表 DDL → ③ 同步 `wwwroot/api/*.dspy` 到宿主应用静态目录 → ④ 注册 RBAC 路径(调 `scripts/load_path.py:register()`)→ ⑤ 跑 `scripts/test_m3a_selfcheck.py` 自检 → ⑥ 输出安装摘要。任一步失败即 `exit 1`fail-closed
### 5.2 宿主应用挂载(`apps/{应用}/app/{应用}.py`
```python
from pbl_compiler import load_pbl_compiler
def get_module_dbname(m):
# 模块 → 库名映射,集中在应用层,模块内禁止硬编码
return {'pbl_compiler': 'pbl', 'pbl_blueprint': 'pbl', 'pbl_common': 'pbl'}.get(m, m)
def init():
env = ServerEnv()
env.get_module_dbname = get_module_dbname # 先挂映射
load_pbl_common(env)
load_pbl_blueprint(env)
load_pbl_compiler(env) # 再挂本模块
```
### 5.3 库名获取(**禁硬编码 DB 名**
```python
# 正确:从宿主应用注入的 ServerEnv 取
def _db(env):
return env.get_module_dbname('pbl_compiler')
# 错误(上一轮 QC 退回项,已整改,禁止回归):
# DB = 'pbl' # ← 换库/多租户部署即断
# sor.R(DB, 'select ...')
```
`api.py``sql_rows/sql_exec/tenant_crud` 一律通过 `env`(或调用方注入的 `db` 参数)取库名;模块内**不存在** `DB = '...'` 常量。自查:
```bash
grep -rn "^DB *=\|DB *= *'pbl'\|DBNAME *= *'" pbl_compiler/ && echo 'HARDCODE_FOUND' || echo 'OK no hardcoded db'
```
### 5.4 RBAC 注册
`scripts/load_path.py``PATHS` 列出全部 15 个契约路径 + 角色(如 `pbl.designer` / `pbl.teacher` / `pbl.admin``register()` 逐条注册并返回统计。**格式串占位符个数必须与参数个数一致**(上一轮 `TypeError: not enough arguments for format string` 已修):
```python
print('[%s] rbac paths: total=%d ok=%d pending=%d' % (MODULE, len(PATHS), done, len(missing)))
print(' PENDING %-12s %s' % (role, path))
```
### 5.5 建表 DDL幂等
```sql
CREATE TABLE IF NOT EXISTS pbl_game_definition (
id VARCHAR(32) PRIMARY KEY, tenant_id VARCHAR(32) NOT NULL,
blueprint_id VARCHAR(32) NOT NULL, blueprint_version INT NOT NULL DEFAULT 1,
gd_version INT NOT NULL DEFAULT 1, content LONGTEXT NOT NULL,
content_fingerprint CHAR(64) NOT NULL, registry_hash CHAR(64),
compiler_version VARCHAR(32), compile_task_id VARCHAR(32),
state VARCHAR(16) NOT NULL DEFAULT 'active',
created_by VARCHAR(32), created_at DATETIME,
UNIQUE KEY uk_tenant_fp (tenant_id, content_fingerprint)
);
CREATE TABLE IF NOT EXISTS pbl_compiler_version (
id VARCHAR(32) PRIMARY KEY, tenant_id VARCHAR(32) NOT NULL,
blueprint_id VARCHAR(32) NOT NULL, blueprint_version INT NOT NULL,
gd_id VARCHAR(32) NOT NULL, version_no INT NOT NULL,
content_fingerprint CHAR(64) NOT NULL, registry_hash CHAR(64),
change_summary TEXT, created_by VARCHAR(32), created_at DATETIME,
UNIQUE KEY uk_tenant_bp_ver (tenant_id, blueprint_id, version_no)
);
CREATE TABLE IF NOT EXISTS pbl_capability_registry (
id VARCHAR(32) PRIMARY KEY, tenant_id VARCHAR(32) NOT NULL,
cap_code VARCHAR(64) NOT NULL, cap_kind VARCHAR(32),
schema_json TEXT, version INT NOT NULL DEFAULT 1,
state VARCHAR(16) NOT NULL DEFAULT 'enabled',
UNIQUE KEY uk_tenant_cap (tenant_id, cap_code, version)
);
CREATE TABLE IF NOT EXISTS pbl_compile_task (
id VARCHAR(32) PRIMARY KEY, tenant_id VARCHAR(32) NOT NULL,
blueprint_id VARCHAR(32) NOT NULL, blueprint_version INT,
trigger VARCHAR(16), state VARCHAR(16) NOT NULL DEFAULT 'pending',
approval_state VARCHAR(16), quality_state VARCHAR(16),
gd_id VARCHAR(32), content_fingerprint CHAR(64),
error_code VARCHAR(32), error_message TEXT,
started_at DATETIME, finished_at DATETIME, created_by VARCHAR(32), created_at DATETIME
);
```
---
## 6. 自检运行方法
```bash
cd modules/pbl_compiler && python3 scripts/test_m3a_selfcheck.py
```
退出码 0 = 全部通过;非 0 = 有断言失败(`build.sh` 第 ⑤ 步会因此中断安装)。**6 组断言**
| 组 | 断言 | 覆盖需求 |
|---|---|---|
| 1 | `canonical_json` 对**同内容不同键序**的两个 dict 产出完全相同的串与指纹 | 29.6 sort_keys |
| 2 | 嵌套结构(含数组/中文/浮点)规范化后**无任何空白字符**,且浮点定点稳定 | 29.6 无空白 + 浮点定点 |
| 3 | `strip_volatile` 后修改 `created_at/id/trace_id` **指纹不变**;修改业务字段**指纹必变** | volatile 不参与指纹 |
| 4 | `build_game_definition()` 输出**恰好 10 个顶层键**,键名集合与预期完全一致,缺失/多余即失败 | GD 契约稳定 |
| 5 | 同一蓝图连续编译 3 次,`content_fingerprint` 三次相等;`registry_hash` 相等 | US-11 / F-CP-03 确定性 |
| 6 | 能力注册表增删一条 `enabled` 能力 → `registry_hash` 变化;仅改 `created_at` → 不变 | registry_hash 语义正确 |
自检脚本**不依赖数据库与宿主应用**(纯函数级),可在 CI 中直接跑;脚本内使用防御式导入,函数名缺失时明确报 `SELFCHECK_FAIL: missing <name>` 而非静默跳过。
---
## 7. 编译主流程 `compile_blueprint()` ①-⑧fail-closed
| 步 | 动作 | 失败处理fail-closed |
|---|---|---|
| ① | **租户与入参校验**`tenant_id` 必传,`blueprint_id` 必传 | 缺失 → `E_TENANT_REQUIRED` / `E_PARAM`,任务置 `rejected`,落审计,**不建 GD** |
| ② | **建编译任务**:写 `pbl_compile_task``state=pending``running`,记 `trigger` | 写库失败 → 直接抛错,不产出任何 GD |
| ③ | **加载蓝图快照**:读 `pbl_blueprint` 聚合根 + 7 类子对象,冻结为内存快照(含 `blueprint_version` | 蓝图不存在/已删除 → `E_BLUEPRINT_NOT_FOUND`,任务 `failed` |
| ④ | **审批门禁**:校验 `approval_state == 'approved'` | 未审批/驳回 → `E_APPROVAL_REQUIRED`,任务 `rejected`**绝不编译** |
| ⑤ | **质量门禁**:取 `pbl_validation``quality_state`,低于阈值(配置化常量)拒绝 | 不达标 → `E_QUALITY_BELOW_THRESHOLD`,任务 `rejected` |
| ⑥ | **构建 GD**`gd_builder.build_game_definition(snapshot, registry)` → 10 顶层键文档;能力缺失即报错,不做静默降级 | 能力未注册/参数不合 schema → `E_CAPABILITY_MISSING`,任务 `failed` |
| ⑦ | **指纹与幂等落库**`strip_volatile``canonical_json``sha256``content_fingerprint``INSERT` 命中 `UNIQUE(tenant_id, content_fingerprint)` 冲突则**回读既有 GD 复用**`reused=true`),否则新建 | 落库异常 → 事务回滚,任务 `failed`,无脏数据 |
| ⑧ | **版本登记 + 审计 + 回填**:写 `pbl_compiler_version``version_no` 递增、`change_summary`),任务置 `succeeded` 并回填 `gd_id/content_fingerprint`,写 `audit_log` | 任一步失败 → 整体回滚,任务 `failed` |
**fail-closed 原则**:③④⑤⑥ 任一不通过 → 无 GD 产出、任务终态可追溯(`error_code` + `error_message`)、审计留痕;**不存在**「先产出再补校验」的路径。`pbl_compiler_preview` 走 ①③⑥⑦ 的**只读子集**(不写 GD、不写版本用于前端试编译。
---
## 8. 陷阱10 条 + 本轮 4 项退回防回归条款)
1. **指纹路径禁易变源**:任何 `now()/uuid/random/pid` 进入 GD 或指纹计算 → 每次编译指纹都不同 → 幂等与 US-11 验收全废。
2. **`strip_volatile` 只剥不改**:剥离白名单外的业务字段会造成「不同蓝图同指纹」的严重误合并。
3. **数组排序要分语义**:把有序步骤序列排序 = 编译产物语义错误;把无序能力集不排序 = 指纹抖动。
4. **浮点必须定点化**`0.1+0.2` 类误差或平台 repr 差异会让同内容产出不同指纹。
5. **唯一约束是幂等的最后防线**:不要「先 SELECT 再 INSERT」当幂等并发下会双写必须依赖 `UNIQUE(tenant_id, content_fingerprint)` + 冲突回读。
6. **`compiler_version` 要参与判断**:编译算法升级后旧指纹不可与新指纹混判等价,跨版本比对需显式重编译。
7. **tenant_id 必须打头**:所有 SQL 的 WHERE 首条件是 `tenant_id`;漏写 = 跨租户数据泄露。
8. **契约三处同步**`.dspy` 存在但 `__init__.py` 未导出 / `init.py` 未注册 → 路由 404且**编译期不报错**,只有运行时才暴露。
9. **格式串占位符与参数个数必须一致**`%d` 多写一个即 `TypeError`,会让 `build.sh` 的 RBAC 步骤整体崩溃。
10. **README/SKILL 不得写占位或元描述**:「见磁盘」「已落盘 N 字符」这类自我指涉文本按空壳造假退回。
### 本轮 4 项退回防回归条款(每次交付前逐条自查)
| 编号 | 退回问题 | 防回归硬条款 | 自查命令 |
|---|---|---|---|
| R1 | 声称写入的 `scripts/test_m3a_selfcheck.py` 磁盘不存在 | 该脚本**必须真实落盘并可执行**,且列入交付文件清单;声称的文件一律先 `wc -c` 复核 | `wc -c scripts/test_m3a_selfcheck.py && python3 scripts/test_m3a_selfcheck.py` |
| R2 | 8 个已定义契约未导出/未注册(含 `verify_determinism``.dspy` 不可达 | api.py `__all__` / `__init__.py` 导出 / `init.py` env 注册 / `load_path.py` PATHS **四处齐全**15 个端点一个不漏 | 见 §3 自查命令 ②③④ |
| R3 | `load_path.py:register()` 格式串占位符错位导致 `TypeError` | 修后必须 `python3 -c` **实测跑通**再交付,禁止只改不跑 | `python3 -c "import sys;sys.path.insert(0,'scripts');import load_path;load_path.register()"` |
| R4 | `api.py` 硬编码 `DB = 'pbl'` | 模块内**不得存在** DB 名常量,一律 `env.get_module_dbname('pbl_compiler')` 或调用方注入 | `grep -rn "DB *= *'" pbl_compiler/ \|\| echo OK` |
> 交付摘要中所有「已写入 / N 字符 / 已落盘」表述,**必须**与交付前 `wc -c``cat``ls` 的实测输出一致;口头声明不能替代落盘(「声称 vs 实测矛盾」按造假直接退回)。
---
## 9. 与 `skill/SKILL.md` 的双向同步
- `skill/SKILL.md` 是**面向 Agent 的操作技能文档**(怎么调、参数、返回、铁律、陷阱),`README.md` 是**面向开发/评审的工程文档**(定位、表、结构、安装、流程)。
- 同步规则(改一处必改另一处):
1. 新增/删除/改名契约端点 → 同步 README §3 表格 + SKILL 端点清单 + `__init__.py`/`init.py`/`load_path.py`
2. 表结构或唯一约束变更 → 同步 README §2 + SKILL 数据契约段 + `models/*.json` + DDL
3. GD 顶层键或 `canonical` 规则变更 → 同步 README §4/§7 + SKILL 确定性铁律段 + 自检脚本断言(第 4/5 组);
4. 新增陷阱/退回条款 → 同步 README §8 + SKILL「陷阱」节。
- 自查:`grep -c 'pbl_' skill/SKILL.md README.md`(两侧端点名应一一对应,无单侧独有)。
---
## 10. 上下游依赖清单
### 上游(本模块依赖)
| 依赖 | 用途 | 缺失影响 |
|---|---|---|
| `pbl_common` | 租户上下文、DB 适配(`sql_rows/sql_exec/tenant_crud`、错误码、审计写入、CRUD 工厂 | 无法取租户/无法落库/无审计,编译不可用 |
| `pbl_blueprint` | 蓝图聚合根 + 7 类子对象 + `blueprint_version`(编译输入源) | ③ 加载快照失败,全流程 fail-closed |
| `pbl_validation` | 14 维校验结论与 `quality_state`(⑤ 质量门禁输入) | 无法判定质量门禁,拒绝编译 |
| `pbl_appcodes` | 枚举编码注入(状态/触发方式/能力类别等字典) | 状态码不合法,前端展示异常 |
| 宿主应用 `ServerEnv` | `get_module_dbname('pbl_compiler')` 库名映射、契约注册环境 | 触发硬编码 DB 名禁项 |
| 基础模块 `sqlor` / `rbac` | 标准 SQL API`sor.C/U/D/R/I/sqlExe`)、权限路径注册 | 数据访问与鉴权不可用 |
### 下游(依赖本模块)
| 模块 | 消费内容 |
|---|---|
| `pbl_agent_runtime`M4a/M4b | 读 GD 作为 Designer/Critic Agent 的世界约束;调 `preview` 试编译 |
| `pbl_evidence`M5a/M5b | 以 `gd_id` / `content_fingerprint` 关联产出物证据 |
| `pbl_assessment`M6 | 按 GD 的 rubric/目标结构做加权评估 |
| `pbl_scense_ext`M9/ `scense_game` | 前端按 GD 渲染可玩场景GD 是唯一渲染契约) |
| `pbl_runtime_ext`M11a/M11b | 运行时事件与状态写入以 GD 定义的实体/规则为准 |
| `pbl_kdb_ext`M7 | 只读引用 GD 指纹做匿名聚合口径对齐 |
### 契约稳定性承诺
GD 的 **10 个顶层键**与 `content_fingerprint` 算法是跨模块契约,属**破坏性变更**范围:任何改动必须升 `gd_version` / `compiler_version`、同步全部下游模块、并重跑 §6 自检 6 组断言。

View File

@ -1,270 +1,379 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_compiler M3a 自检脚本QC #1 整改:真实落盘,可执行)。
"""M3a 自检脚本 —— pbl_compiler 确定性编译内核canonical + gd_builder
运行模块仓库根目录::
运行
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 = 6 组断言全部通过1 = 有断言失败2 = 被测模块加载失败
``apps/pbls/build.sh`` 9 py_compile之后可直接串接本脚本做部署前门禁
退出码 0 = 全部通过 0 = 有断言失败打印 FAIL 明细
设计约束
--------
* **纯函数级自检**只加载 ``pbl_compiler/canonical.py`` ``pbl_compiler/gd_builder.py``
两个纯函数模块不连数据库不依赖宿主应用 ``ServerEnv``不需要 ``ahserver``
故用 importlib 按文件路径加载绕开包 ``__init__.py`` ahserver 依赖
* **防御式导入**被测函数缺失时打印 ``SELFCHECK_FAIL: missing <name>`` 并计入失败
绝不静默跳过上一轮 QC 退回项 R1声称的自检脚本必须真实可跑
* 6 组断言与 ``README.md`` §6 表格一一对应
"""
from __future__ import annotations
import ast
import io
import copy
import hashlib
import importlib.util
import json
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 依赖
PKG_DIR = os.path.join(MOD_ROOT, 'pbl_compiler')
# ---- 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 = [], []
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 ''))
# --------------------------------------------------------------------------
# 输出与断言工具
# --------------------------------------------------------------------------
def _ok(group, msg):
PASS.append('[%s] %s' % (group, msg))
print(' PASS [%s] %s' % (group, msg))
# 一个最小但真实的蓝图版本快照(含 7 类子对象若干 + 易变字段)
def _bad(group, msg):
FAIL.append('[%s] %s' % (group, msg))
print(' FAIL [%s] %s' % (group, msg))
def _check(group, cond, msg):
"""断言True → PASSFalse → FAIL。返回 bool 便于短路后续依赖断言。"""
if cond:
_ok(group, msg)
else:
_bad(group, msg)
return bool(cond)
# --------------------------------------------------------------------------
# 按文件路径加载纯函数模块(避开 pbl_compiler/__init__.py 的 ahserver 依赖)
# --------------------------------------------------------------------------
def _load_pure_modules():
"""返回 (canonical_mod, gd_builder_mod);任一失败返回 (None, None)。"""
pkg_name = 'pbl_compiler'
if pkg_name not in sys.modules:
pkg = types.ModuleType(pkg_name)
pkg.__path__ = [PKG_DIR]
sys.modules[pkg_name] = pkg
def _load(sub):
path = os.path.join(PKG_DIR, sub + '.py')
if not os.path.isfile(path):
print('SELFCHECK_FAIL: missing file %s' % path)
return None
full = '%s.%s' % (pkg_name, sub)
spec = importlib.util.spec_from_file_location(full, path)
if spec is None or spec.loader is None:
print('SELFCHECK_FAIL: cannot build import spec for %s' % path)
return None
mod = importlib.util.module_from_spec(spec)
sys.modules[full] = mod
try:
spec.loader.exec_module(mod)
except Exception as exc: # noqa: BLE001
print('SELFCHECK_FAIL: import %s raised %s: %s'
% (full, type(exc).__name__, exc))
return None
setattr(sys.modules[pkg_name], sub, mod)
return mod
canonical = _load('canonical')
gd_builder = _load('gd_builder') if canonical is not None else None
return canonical, gd_builder
def _pick(mod, names, group):
"""按候选名取可调用对象;全部缺失 → 明确报 missing 并计 FAIL。"""
if mod is None:
return None
for name in names:
fn = getattr(mod, name, None)
if callable(fn):
return fn
print('SELFCHECK_FAIL: missing %s in %s'
% ('/'.join(names), getattr(mod, '__name__', '?')))
_bad(group, 'missing callable %s' % '/'.join(names))
return None
# --------------------------------------------------------------------------
# 参考实现(用于交叉验证「规则本身」,不作为通过依据)
# --------------------------------------------------------------------------
def _ref_canonical(obj):
return json.dumps(obj, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
def _ref_fingerprint(obj):
return hashlib.sha256(_ref_canonical(obj).encode('utf-8')).hexdigest()
# --------------------------------------------------------------------------
# 测试夹具
# --------------------------------------------------------------------------
def _snapshot():
"""最小可用蓝图版本快照7 类子对象中的 4 类,键名走 M1a 落库形态)。"""
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}],
'subobjects': {
'project': [{'code': 'pj1', 'name': '火星基地', 'title': '火星基地'}],
'problem': [{'code': 'pb1', 'name': '氧气不足',
'driving_question': '如何在 30 天内自给氧气?'}],
'learning_goals': [{'code': 'g1', 'name': '光合作用', 'weight': 0.5}],
'scenes': [{'code': 's1', 'name': '温室'},
{'code': 's2', 'name': '控制室'}],
'roles': [{'code': 'r1', 'name': '生物学家',
'capability_key': 'pbl.role.skill'}],
'missions': [{'code': 'm1', 'name': '建温室', 'seq': 1}],
'events': [{'code': 'e1', 'name': '进入温室', 'trigger': 'enter',
'response': [{'capability': 'pbl.item.consume'}]}],
'artifacts': [{'code': 'a1', 'name': '氧气报告'}],
'evidence_specs': [{'code': 'ev1', 'name': '实验记录'}],
'rubrics': [{'code': 'rb1', 'name': '协作', 'weight': 1.0}],
'assets': [{'code': 'as1', 'name': '温室模型.glb', 'kind': 'model'}],
}
}
}
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 _ctx(**over):
ctx = {
'blueprint_id': 1001,
'blueprint_version_no': 3,
'compiler_version': '1.0.0',
'ruleset_version': 'pbl.rules.v1',
'rules_hash': 'deadbeefdeadbeef',
'created_at': '2026-09-15 09:40:04',
'duration_ms': 7,
'task_no': 'CT-T1-1001-3-000001',
}
ctx.update(over)
return ctx
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 _registry():
return [
{'capability_key': 'pbl.role.skill', 'category': 'role', 'version_no': 1,
'args_schema_json': '{"level":"int"}', 'permission_required': '',
'is_enabled': True},
{'capability_key': 'pbl.item.consume', 'category': 'item', 'version_no': 1,
'args_schema_json': '{"count":"int"}', 'permission_required': '',
'is_enabled': True},
]
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 _build(f_build, snapshot, ctx, registry):
"""调用 build_game_definition 并归一化返回值为 (gd, fp)。"""
out = f_build(snapshot, ctx, registry)
if isinstance(out, tuple) and len(out) == 2:
return out[0], out[1]
if isinstance(out, dict):
fp = (out.get('manifest') or {}).get('fingerprint')
return out, fp
raise AssertionError('build_game_definition 返回类型异常: %r' % type(out).__name__)
# --------------------------------------------------------------------------
# 主流程6 组断言
# --------------------------------------------------------------------------
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):
print('=== pbl_compiler M3a selfcheck (6 groups) ===')
print('module root : %s' % MOD_ROOT)
canonical, gd_builder = _load_pure_modules()
if canonical is None or gd_builder is None:
print('SELFCHECK_FAIL: canonical/gd_builder 加载失败,自检中止')
return 2
f_canon = _pick(canonical, ('canonical_json',), 'G1')
f_strip = _pick(canonical, ('strip_volatile',), 'G3')
f_fp = _pick(canonical, ('sha256_fingerprint', 'fingerprint'), 'G1')
f_reghash = _pick(canonical, ('registry_hash',), 'G6')
f_build = _pick(gd_builder, ('build_game_definition',), 'G4')
top_keys = getattr(gd_builder, 'GD_TOP_KEYS', None)
err_cls = getattr(canonical, 'CanonicalError', ValueError)
if f_canon is None or f_fp is None or f_build is None:
print('SELFCHECK_FAIL: 核心函数缺失,自检中止')
return 2
# ---------------- G1 sort_keys键序无关 + 指纹形态 ----------------
print('-- G1 canonical 键序无关 / 指纹形态 --')
a = {'name': 'quest-1', 'meta': {'z': 1, 'a': 2}, 'tags': ['x', 'y']}
b = {'tags': ['x', 'y'], 'name': 'quest-1', 'meta': {'a': 2, 'z': 1}}
ca, cb = f_canon(a), f_canon(b)
_check('G1', ca == cb, '同内容不同键序 → canonical_json 完全相同')
_check('G1', f_fp(a) == f_fp(b), '同内容不同键序 → sha256_fingerprint 相同')
fp_a = f_fp(a)
_check('G1', isinstance(fp_a, str) and len(fp_a) == 64
and all(ch in '0123456789abcdef' for ch in fp_a),
'指纹为 64 位小写十六进制 sha256实测 %s…)' % fp_a[:16])
_check('G1', ca == _ref_canonical(a),
'canonical_json 与参考实现(sort_keys=True + separators=(",",":"))一致')
_check('G1', f_fp(a) != f_fp({'name': 'quest-2', 'meta': {'z': 1, 'a': 2},
'tags': ['x', 'y']}),
'内容变更 → 指纹必变')
# ---------------- G2 零空白 / 中文不转义 / 浮点定点 / 拒 NaN ----------------
print('-- G2 零空白 / ensure_ascii=False / 浮点定点 / NaN 拒绝 --')
nested = {'规则': {'概率': 0.5, 'n': 3, 'list': [{'b': 1.0, 'a': '中文值'}]}}
cn = f_canon(nested)
_check('G2', not any(ch in cn for ch in ' \n\t\r'),
'规范化串不含任何空白字符(零空白分隔符)')
_check('G2', '\\u' not in cn, 'ensure_ascii=False中文原样保留不被转义')
_check('G2', f_canon(json.loads(cn)) == cn,
'规范化幂等canonical(json.loads(canonical(x))) == canonical(x)')
_check('G2', f_fp({'v': 0.1 + 0.2}) == f_fp({'v': 0.3}),
'浮点定点化0.1+0.2 与 0.3 指纹相同FLOAT_PRECISION 量化)')
_check('G2', f_fp({'v': 1.0}) == f_fp({'v': 1}),
'整值浮点与整数指纹相同(定点化去尾零)')
try:
fn()
f_canon({'v': float('nan')})
_check('G2', False, 'NaN 应被拒绝allow_nan=False')
except err_cls:
_check('G2', True, 'NaN 触发 CanonicalError非有限浮点不可规范化')
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)))
_check('G2', False, 'NaN 抛出非预期异常 %s: %s' % (type(exc).__name__, exc))
# ---------------- G3 strip_volatile易变字段不参与指纹 ----------------
print('-- G3 strip_volatile 易变字段不参与指纹 --')
base = {'blueprint_id': 1001, 'gd_version': 1,
'content': {'entities': [{'code': 'e1', 'hp': 100}]}}
v1 = dict(base, created_at='2026-09-15 09:40:04', id=11, trace_id='t-1',
duration_ms=5, task_no='CT-1')
v2 = dict(base, created_at='2030-01-01 00:00:00', id=99, trace_id='t-9',
duration_ms=888, task_no='CT-9')
_check('G3', f_fp(v1) == f_fp(v2),
'created_at/id/trace_id/duration_ms/task_no 变化 → 指纹不变')
_check('G3', f_fp(v1) == f_fp(base), '易变字段整体存在与否不影响指纹')
changed = copy.deepcopy(base)
changed['content']['entities'][0]['hp'] = 101
_check('G3', f_fp(changed) != f_fp(base), '业务字段(hp)变化 → 指纹必变')
if f_strip is not None:
stripped = f_strip(v1)
_check('G3', not any(k in stripped for k in
('created_at', 'id', 'trace_id', 'duration_ms', 'task_no')),
'strip_volatile 确实剥离全部易变键')
_check('G3', stripped.get('content') == base['content'],
'strip_volatile 只剥不改:业务子树原样保留')
_check('G3', f_strip({'a': [{'created_at': 'x', 'k': 1}]}) == {'a': [{'k': 1}]},
'strip_volatile 递归进入 list[dict]')
_check('G3', f_fp(base) == _ref_fingerprint(f_strip(v1)) if f_strip else False,
'模块指纹 == sha256(参考规范化(strip_volatile(x)))(算法口径一致)')
# ---------------- G4 GD 恰好 10 个顶层键 ----------------
print('-- G4 Game Definition 10 顶层键 --')
EXPECTED = ('manifest', 'pbl', 'world', 'scenes', 'entities', 'events',
'states', 'capabilities', 'assessment', 'assets')
_check('G4', tuple(top_keys or ()) == EXPECTED,
'gd_builder.GD_TOP_KEYS == 10 键契约(实测 %s' % (top_keys,))
snap = _snapshot()
try:
gd, fp = _build(f_build, snap, _ctx(), _registry())
except Exception as exc: # noqa: BLE001
gd, fp = None, None
_bad('G4', 'build_game_definition 抛异常 %s: %s' % (type(exc).__name__, exc))
if isinstance(gd, dict):
keys = tuple(gd.keys())
_check('G4', len(keys) == 10, 'GD 顶层键数量 == 10实测 %d' % len(keys))
_check('G4', set(keys) == set(EXPECTED),
'GD 顶层键集合一致;缺=%s 多=%s'
% (sorted(set(EXPECTED) - set(keys)), sorted(set(keys) - set(EXPECTED))))
_check('G4', isinstance(fp, str) and len(fp) == 64,
'build_game_definition 同时返回 64 位指纹')
_check('G4', gd['manifest'].get('fingerprint') == fp,
'manifest.fingerprint 与返回指纹一致')
_check('G4', gd['manifest'].get('schema') == 'pbl.game_definition.v1'
and gd['manifest'].get('deterministic') is True
and gd['manifest'].get('llmUsed') is False,
'manifest 声明 schema/deterministic=True/llmUsed=False零 LLM')
_check('G4', gd['manifest'].get('registryHash') == f_reghash(_registry()),
'manifest.registryHash == canonical.registry_hash(能力注册表)')
# ---------------- G5 同输入重编译 N 次指纹全等US-11 / F-CP-03 ----------------
print('-- G5 确定性:同输入重编译指纹全等 --')
fps = []
for i in range(3):
try:
_gd, _fp = _build(f_build, copy.deepcopy(snap), _ctx(), _registry())
fps.append(_fp)
except Exception as exc: # noqa: BLE001
_bad('G5', '%d 次编译抛异常 %s: %s' % (i + 1, type(exc).__name__, exc))
_check('G5', len(fps) == 3 and len(set(fps)) == 1,
'连续 3 次编译指纹全等(%s' % (fps[0][:16] if fps else 'N/A'))
try:
_gd_v, fp_v = _build(f_build, copy.deepcopy(snap),
_ctx(created_at='2031-12-31 23:59:59', duration_ms=9999,
task_no='CT-OTHER-999'), _registry())
_check('G5', fps and fp_v == fps[0],
'ctx 易变字段(created_at/duration_ms/task_no)变化 → 指纹不变')
except Exception as exc: # noqa: BLE001
_bad('G5', '易变 ctx 编译抛异常 %s: %s' % (type(exc).__name__, exc))
snap2 = copy.deepcopy(snap)
subs = snap2['content']['subobjects']
subs['scenes'] = list(reversed(subs['scenes']))
try:
_gd_o, fp_o = _build(f_build, snap2, _ctx(), _registry())
_check('G5', fps and fp_o == fps[0],
'子对象入库顺序颠倒 → 指纹不变(集合稳定化排序生效)')
except Exception as exc: # noqa: BLE001
_bad('G5', '乱序快照编译抛异常 %s: %s' % (type(exc).__name__, exc))
snap3 = copy.deepcopy(snap)
snap3['content']['subobjects']['roles'][0]['name'] = '工程师'
try:
_gd_c, fp_c = _build(f_build, snap3, _ctx(), _registry())
_check('G5', fps and fp_c != fps[0], '业务内容变更 → 指纹必变')
except Exception as exc: # noqa: BLE001
_bad('G5', '变更快照编译抛异常 %s: %s' % (type(exc).__name__, exc))
# ---------------- G6 registry_hash 语义 ----------------
print('-- G6 registry_hash 能力注册表指纹 --')
if f_reghash is not None:
reg = _registry()
h1 = f_reghash(reg)
_check('G6', isinstance(h1, str) and len(h1) == 64,
'registry_hash 为 64 位 sha256%s…)' % h1[:16])
_check('G6', f_reghash(list(reversed(reg))) == h1,
'registry_hash 与能力行顺序无关')
reg_more = reg + [{'capability_key': 'pbl.event.broadcast', 'category': 'event',
'version_no': 1, 'args_schema_json': '{}',
'permission_required': '', 'is_enabled': True}]
_check('G6', f_reghash(reg_more) != h1, '新增一条能力 → registry_hash 变化')
reg_bump = [dict(r) for r in reg]
reg_bump[0]['version_no'] = 2
_check('G6', f_reghash(reg_bump) != h1, '能力 version_no 升级 → registry_hash 变化')
reg_schema = [dict(r) for r in reg]
reg_schema[0]['args_schema_json'] = '{"level":"str"}'
_check('G6', f_reghash(reg_schema) != h1, 'args_schema 变更 → registry_hash 变化')
reg_ts = [dict(r, created_at='2030-01-01 00:00:00', id=777) for r in reg]
_check('G6', f_reghash(reg_ts) == h1,
'仅改 created_at/id 等易变字段 → registry_hash 不变')
_check('G6', f_reghash([]) != h1 and len(f_reghash([])) == 64,
'空注册表有确定哈希且与非空不同')
else:
_bad('G6', 'canonical.registry_hash 不可用')
print('--- summary: pass=%d fail=%d ---' % (len(PASS), len(FAIL)))
if FAIL:
print('FAILED: %s' % FAIL)
for item in FAIL:
print('SELFCHECK_FAIL %s' % item)
return 1
print('ALL GREEN')
print('SELFCHECK_OK 6 groups all passed (G1..G6)')
return 0
# ---- pytest 兼容 ----
def test_m3a_selfcheck():
assert main() == 0
if __name__ == '__main__':
sys.exit(main())