holoaeon / scripts /admit_batch.py
weideyouni's picture
sync 53fcc1a..d9a2637
a24a73f verified
Raw History Blame Contribute Delete
15.9 kB
# -*- coding: utf-8 -*-
"""SOP-10 · B7 分批准入(每批 ≤5,累计 ≤15,四硬闸 + 人工签字)。
用法:
# 出票:跑四硬闸 + 生成准入票据 + 写台账(待签字)
python scripts/admit_batch.py --batch B7-1 --out data/B7_准入票据_B7-1.md
# 复核:只读重算(不跑闸),落票据
python scripts/admit_batch.py --check --json reports/b7_admission_check.json
# 人工签字:缺签字票据不作废,但不得进执行层
python scripts/admit_batch.py --sign --sign-by "姓名" --signed-at 2026-11-02
设计约束:
1. 候选只从 B5 池取(`data/semantic_family.json` 的 bridge_candidates + members);
不在池内 → 直接拒绝出票。
2. 条数不补齐:票上几条就写几条,凑 5 条视为违规(A9)。
3. 四硬闸:v3_gate / unittest / verify_skill_matrix / redline_scan。
4. 签字是人工动作:脚本只记录「谁、何时」,不自称签字。
"""
from pathlib import Path
import argparse
import json
import os
import re
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from holoaeon.fusion_upgrade import admission as ADM # noqa: E402
REPO = ROOT
RULES = os.path.join(REPO, "data", "admission_rules.json")
LEDGER = os.path.join(REPO, "data", "admission_ledger.csv")
FAMILY = os.path.join(REPO, "data", "semantic_family.json")
GATES_JSON = os.path.join(REPO, "reports", "b7_gates.json")
V3_JSON = os.path.join(REPO, "reports", "gate_fusion_v4.json")
CHECK_JSON = os.path.join(REPO, "reports", "b7_admission_check.json")
def read_json(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def write_json(path, doc):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
def run(cmd):
env = dict(os.environ, PYTHONIOENCODING="utf-8")
p = subprocess.run(cmd, cwd=REPO, capture_output=True, text=True,
encoding="utf-8", errors="replace", env=env)
return p.returncode, (p.stdout or "") + (p.stderr or "")
def run_gates():
"""跑四道硬闸并解析成 {gate_id: {passed,total,ok,cmd,raw_tail}}。"""
out = {}
for gid, cmd in ADM.HARD_GATES:
rc, text = run(cmd.split())
tail = text.strip().splitlines()[-1] if text.strip() else ""
passed = total = None
if gid == "v3_gate":
try:
d = read_json(V3_JSON)
passed, total = d.get("passed"), d.get("total")
except Exception:
pass
elif gid == "unittest":
m = re.search(r"Ran (\d+) tests", text)
if m:
passed = total = int(m.group(1))
elif gid == "skill_matrix":
try:
d = json.loads(text[text.index("{"):])
st = d.get("selftest", {})
passed = int(st.get("passed") or 0)
total = len(st.get("detail") or [])
except Exception:
pass
elif gid == "redline":
m = re.search(r"扫描文件数:(\d+)", text)
if m:
passed = total = int(m.group(1))
ok = rc == 0 and bool(passed) and passed == total
out[gid] = {"passed": passed, "total": total, "ok": ok, "cmd": cmd,
"raw_tail": tail[:200]}
return out
def load_pool():
fam = read_json(FAMILY)
skills = {c["skill"] for c in fam.get("bridge_candidates", [])}
skills |= {m.get("skill") for m in fam.get("members", []) if m.get("skill")}
return fam, skills
def build(batch, out_path, skills_arg=None):
fam, pool = load_pool()
if skills_arg:
chosen = [s.strip() for s in skills_arg.split(",") if s.strip()]
else:
chosen = [c["skill"] for c in fam.get("bridge_candidates", [])]
outside = [s for s in chosen if s not in pool]
if outside:
print(json.dumps({"verdict": "REFUSED", "reason": "outside_pool",
"skills": outside}, ensure_ascii=False))
return 2
if len(chosen) > ADM.MAX_PER_BATCH:
print(json.dumps({"verdict": "REFUSED", "reason": "over_per_batch",
"count": len(chosen)}, ensure_ascii=False))
return 2
cand = {c["skill"]: c for c in fam.get("bridge_candidates", [])}
for m in fam.get("members", []):
cand.setdefault(m.get("skill"), m)
gates = run_gates()
write_json(GATES_JSON, {"gates_id": "SG-B7-HARD-GATES-V1", "batch": batch,
"evidence_class": ADM.EVIDENCE_CLASS, "gates": gates})
gate_cells = {gid: ("%s/%s" % (gates[gid]["passed"], gates[gid]["total"])
if gates[gid]["passed"] else "FAIL") for gid in ADM.GATE_IDS}
rows = []
for s in chosen:
c = cand.get(s, {})
rows.append({
"batch": batch, "ticket": out_path, "skill": s,
"target_mechanism": c.get("target_mechanism", "semantic_bus"),
"module": c.get("module", ""), "usage_class": c.get("label", ""),
"gate_v3_gate": gate_cells["v3_gate"],
"gate_unittest": gate_cells["unittest"],
"gate_skill_matrix": gate_cells["skill_matrix"],
"gate_redline": gate_cells["redline"],
"signature_status": "PENDING", "signer": "", "signed_at": "",
"decision": ADM.DECISION_WAITING,
"note": "B5 candidate_bridge;未签字不进执行层",
})
# 幂等:同批行整段替换,其它批保留
old = []
if os.path.isfile(LEDGER):
old = ADM.parse_ledger(Path(LEDGER).read_text(encoding="utf-8"))
kept = [r for r in old if r.get("batch") != batch]
allrows = kept + rows
os.makedirs(os.path.dirname(LEDGER), exist_ok=True)
open(LEDGER, "w", encoding="utf-8", newline="\n").write(ADM.dump_ledger(allrows))
res = ADM.validate(allrows, pool=pool, gate_results=gates)
write_ticket(out_path, batch, rows, gates, res)
print(json.dumps({"batch": batch, "ticket": out_path, "rows": len(rows),
"verdict": res["verdict"], "gates_ok": all(g["ok"] for g in gates.values()),
"admitted": res["counts"]["admitted"],
"remaining": res["counts"]["remaining"]}, ensure_ascii=False))
return 0
def write_ticket(path, batch, rows, gates, res, signature=None):
g = gates
ok_all = all(v["ok"] for v in g.values())
lines = []
lines.append("# B7 分批准入票据 · %s" % batch)
lines.append("")
lines.append("> 规则 `%s` · 每批 ≤%d · 累计 ≤%d · 四硬闸 + 人工签字"
% (ADM.RULES_ID, ADM.MAX_PER_BATCH, ADM.MAX_CUMULATIVE))
lines.append("> evidence_class=`%s`(仿真/离线证据,不等于治疗证据)" % ADM.EVIDENCE_CLASS)
lines.append("")
lines.append("## 一、判据与来源")
lines.append("")
lines.append("判据取自 `holoaeon/fusion_upgrade/plan.py` B7 段与 SOP-10:")
lines.append("")
lines.append("1. 每批 ≤%d 个技能;" % ADM.MAX_PER_BATCH)
lines.append("2. 四道硬闸全绿(v3_gate / unittest / verify_skill_matrix / redline_scan);")
lines.append("3. 每批有签字人姓名与日期;缺签字 → `WAITING_SIGNATURE`,不进执行层;")
lines.append("4. 累计准入 ≤%d;" % ADM.MAX_CUMULATIVE)
lines.append("5. 准入票据存在(本文件)。")
lines.append("")
lines.append("## 二、本批候选(只从 B5 池取,不扩池、不补齐)")
lines.append("")
lines.append("| # | 技能 | 目标机制 | 模块 | B3 标签 | 来源状态 |")
lines.append("| --- | --- | --- | --- | --- | --- |")
for i, r in enumerate(rows, 1):
lines.append("| %d | `%s` | %s | %s | %s | candidate_bridge |"
% (i, r["skill"], r["target_mechanism"], r["module"], r["usage_class"]))
lines.append("")
k_count = sum(1 for r in rows if r["usage_class"] == "K")
lines.append("本批 **%d 条**(≤%d,未按 5 补齐)。全部取自 B5 登记池(bridge_candidates + 族成员),"
"其中 B3 标签 K(知识源)%d 条、非 K %d 条 —— 非 K 条目须人工复核后才可进执行层。"
% (len(rows), ADM.MAX_PER_BATCH, k_count, len(rows) - k_count))
lines.append("")
lines.append("## 三、四道硬闸实测")
lines.append("")
lines.append("| 闸 | 命令 | 结果 | 判定 |")
lines.append("| --- | --- | --- | --- |")
for gid, _cmd in ADM.HARD_GATES:
v = g[gid]
lines.append("| %s | `%s` | %s/%s | %s |"
% (gid, v["cmd"], v["passed"], v["total"], "PASS" if v["ok"] else "FAIL"))
lines.append("")
lines.append("闸全绿:**%s**;闸票据 `reports/b7_gates.json`。" % ("是" if ok_all else "否"))
lines.append("")
lines.append("## 四、准入结论")
lines.append("")
lines.append("- 判定:`%s`" % (signature["decision"] if signature else res["verdict"]))
lines.append("- 已准入累计:**%d / %d**(剩余 %d)"
% (res["counts"]["admitted"], ADM.MAX_CUMULATIVE, res["counts"]["remaining"]))
lines.append("- 未签字前,本批 %d 条技能**不进执行层**,`kernel.skills` 恒 47。" % len(rows))
lines.append("")
lines.append("## 五、人工签字")
lines.append("")
if signature:
lines.append("- 签字人:**%s**" % signature["signer"])
lines.append("- 签字日期:**%s**" % signature["signed_at"])
lines.append("- 签字后结论:`ADMITTED`(仍不计入 `kernel.skills`,只解除执行层禁令)")
else:
lines.append("待签字(签名与日期两项缺一不可;缺签字票据不作废但不得进执行层)。")
lines.append("")
lines.append("```")
lines.append("签字人:____________________ 日期:____________________")
lines.append("```")
lines.append("")
lines.append("## 六、可复现")
lines.append("")
lines.append("```bash")
lines.append("python scripts/admit_batch.py --batch %s --out %s # 出票" % (batch, path))
lines.append("python scripts/admit_batch.py --check --json reports/b7_admission_check.json")
lines.append("python scripts/admit_batch.py --sign --sign-by \"姓名\" --signed-at 2026-11-02")
lines.append("python -m unittest discover -s tests_contract # 旁挂全部单测")
lines.append("```")
lines.append("")
lines.append("## 七、维度不变自证")
lines.append("")
lines.append("| 维度 | 锁值 | 本批后 | 结论 |")
lines.append("| --- | --- | --- | --- |")
lines.append("| agents | 25 | 25 | 未动 |")
lines.append("| skills | 47 | 47 | 未动(准入只改登记态,不进 `kernel.skills`) |")
lines.append("| packs | 9 | 9 | 未动(未新增 pack) |")
lines.append("| spec.VERSION | 0.9.1 | 0.9.1 | 未动 |")
lines.append("")
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "w", encoding="utf-8", newline="\n").write("\n".join(lines) + "\n")
def check(json_out=None):
rules_sha_ok = True
if os.path.isfile(RULES):
rules_sha_ok = read_json(RULES).get("sha256") == ADM.rules_doc()["sha256"]
if not os.path.isfile(LEDGER):
print(json.dumps({"verdict": "FAIL", "reason": "ledger_missing"}, ensure_ascii=False))
return 1
rows = ADM.parse_ledger(Path(LEDGER).read_text(encoding="utf-8"))
fam, pool = load_pool()
gates = read_json(GATES_JSON)["gates"] if os.path.isfile(GATES_JSON) else None
tickets = {}
for b in sorted({r["batch"] for r in rows}):
p = next(r["ticket"] for r in rows if r["batch"] == b)
if os.path.isfile(p):
tickets[b] = Path(p).read_text(encoding="utf-8")
res = ADM.validate(rows, pool=pool, ticket_texts=tickets, gate_results=gates)
extra = []
if not rules_sha_ok:
extra.append("rules_sha256_drift")
payload = {
"check_id": "SG-B7-ADMISSION-CHECK-V1",
"rules_id": ADM.RULES_ID,
"evidence_class": ADM.EVIDENCE_CLASS,
"verdict": "FAIL" if extra else res["verdict"],
"failed": extra + res["violations"],
"checks": res["checks"],
"counts": res["counts"],
"rules_sha256_match": rules_sha_ok,
"gates": {k: {"passed": v["passed"], "total": v["total"], "ok": v["ok"]}
for k, v in (gates or {}).items()},
}
if json_out:
write_json(json_out, payload)
print(json.dumps({k: payload[k] for k in ("verdict", "failed", "counts")},
ensure_ascii=False))
return 0 if payload["verdict"] in ("PASS", ADM.DECISION_WAITING) else 1
def sign(batch, signer, signed_at):
if not signer or not signed_at:
print(json.dumps({"verdict": "REFUSED", "reason": "signature_incomplete"},
ensure_ascii=False))
return 2
if not os.path.isfile(LEDGER):
print(json.dumps({"verdict": "REFUSED", "reason": "ledger_missing"}, ensure_ascii=False))
return 2
rows = ADM.parse_ledger(Path(LEDGER).read_text(encoding="utf-8"))
hit = [r for r in rows if r["batch"] == batch]
if not hit:
print(json.dumps({"verdict": "REFUSED", "reason": "batch_not_found"}, ensure_ascii=False))
return 2
if ADM.admitted_count(rows) + len(hit) > ADM.MAX_CUMULATIVE:
print(json.dumps({"verdict": "REFUSED", "reason": "over_cumulative",
"admitted": ADM.admitted_count(rows), "batch": len(hit)}, ensure_ascii=False))
return 2
gates = read_json(GATES_JSON)["gates"] if os.path.isfile(GATES_JSON) else None
if gates and not all(v["ok"] for v in gates.values()):
print(json.dumps({"verdict": "REFUSED", "reason": "hard_gate_red"}, ensure_ascii=False))
return 2
for r in rows:
if r["batch"] == batch:
r["signature_status"] = "SIGNED"
r["signer"] = signer
r["signed_at"] = signed_at
r["decision"] = ADM.DECISION_ADMITTED
open(LEDGER, "w", encoding="utf-8", newline="\n").write(ADM.dump_ledger(rows))
ticket = hit[0]["ticket"]
fam, pool = load_pool()
tickets = {}
for b in sorted({r["batch"] for r in rows}):
p = next(r["ticket"] for r in rows if r["batch"] == b)
if os.path.isfile(p):
tickets[b] = Path(p).read_text(encoding="utf-8")
res = ADM.validate(rows, pool=pool, ticket_texts=tickets, gate_results=gates)
batch_only = [r for r in rows if r["batch"] == batch]
write_ticket(ticket, batch, batch_only, gates or {}, res,
signature={"signer": signer, "signed_at": signed_at, "decision": "ADMITTED"})
print(json.dumps({"batch": batch, "signed_by": signer, "signed_at": signed_at,
"admitted": res["counts"]["admitted"],
"remaining": res["counts"]["remaining"]}, ensure_ascii=False))
return 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--batch", default="B7-1")
ap.add_argument("--skills", default="")
ap.add_argument("--out", default="")
ap.add_argument("--check", action="store_true")
ap.add_argument("--json", default="")
ap.add_argument("--sign", action="store_true")
ap.add_argument("--sign-by", default="")
ap.add_argument("--signed-at", default="")
a = ap.parse_args()
if a.sign:
return sign(a.batch, a.sign_by, a.signed_at)
if a.check:
return check(a.json or None)
out = a.out or os.path.join(REPO, "data", "B7_准入票据_%s.md" % a.batch)
return build(a.batch, out, a.skills or None)
if __name__ == "__main__":
sys.exit(main())