analysis_lock.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. """ON-0 分析前置锁定门 · 焊点1 (SOP §0.8; 2026-07-29 用户裁 "选 a 手动加一行")。
  2. **用法 = 每个分析脚本第一行**::
  3. from src.sop.analysis_lock import assert_frozen
  4. LOCK = assert_frozen("changyi") # 未冻结 / 未过自洽闸 / 找不到本场锁 → 立即 exit 2, 不出数
  5. 返回锁 dict, 调用方可直接取窗/口径 (免二次读, 也避免"锁一份、脚本里另写一份"的漂移)。
  6. 为什么是"手动加一行"而不是 import 时自动检查 (用户 2026-07-29 二选一裁决):
  7. 显式可读 —— 看脚本头就知道这道门在; 代价 = **新脚本可能忘加**。
  8. 该漏口用 sop_lint 断言在**已声明入口**上兜底 (见 ENTRYPOINTS), 未声明的新脚本仍靠纪律。
  9. **头号设计坑 (本模块存在的主要理由)**: `configs/analysis_lock.yaml` 是**某一场**的锁 (现为花园口)。
  10. 若某场没有自己的 `analysis_lock_<farm>.yaml` 就回落到它, 校验会拿**别场的锁**判 PASS = **静默假过**,
  11. 恰是本门要防的病的加强版。故: 回落**必须**校验锁内 farm 字段与请求场一致, 不一致 = 拒绝, 不是警告。
  12. """
  13. from __future__ import annotations
  14. try:
  15. from app_common.app_common_guanlan.api import install_root as _install_root
  16. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  17. from pathlib import Path as _P
  18. def _install_root(_f): return _P(_f).resolve().parents[4]
  19. from src import paths as P
  20. import os
  21. import sys
  22. from pathlib import Path
  23. _REPO = _install_root(__file__)
  24. # sop_lint 兜底覆盖面 = 这些"当代分析入口"必须调 assert_frozen (漏加即断言 FAIL)。
  25. # 刻意**不含** 1000+ 历史 per-场脚本 (CLAUDE.md R0 "不动骨架": 那批已跑完, 批量改写风险 >> 收益)。
  26. ENTRYPOINTS = (
  27. "src/sop/farm_pipeline.py",
  28. "scripts/run_scenario.py",
  29. )
  30. # 焊点2 首付登记: farm → {path, sha, ts, strict}。assert_frozen 写, assert_unchanged 读。
  31. # 进程内内存态 (一次分析 = 一个进程); 跨进程留痕走 outputs/<farm>/sop/on0_lock_trail.jsonl。
  32. _RUNS: dict = {}
  33. def _sha(path: Path) -> str:
  34. import hashlib
  35. return hashlib.sha256(Path(path).read_bytes()).hexdigest()
  36. def _trail(root: Path, farm: str, rec: dict) -> None:
  37. """append-only 留痕 (§0.8 焊点2 "记入日志" 的落点; §9.5 报告级核对仍待实现, 本文件只覆盖 ON-0 这一片)。"""
  38. import json
  39. from datetime import datetime
  40. try:
  41. d = Path(root) / P.rel(P.sop(farm))
  42. d.mkdir(parents=True, exist_ok=True)
  43. rec = {"ts": datetime.now().isoformat(timespec="seconds"), **rec}
  44. with (d / "on0_lock_trail.jsonl").open("a", encoding="utf-8") as f:
  45. f.write(json.dumps(rec, ensure_ascii=False) + "\n")
  46. except Exception as e: # 留痕失败不该杀分析, 但必须响亮
  47. print(f"[ON-0] ⚠ 哈希留痕写失败 ({type(e).__name__}: {e}) — 闭环留痕缺这一条", file=sys.stderr)
  48. def _norm(s) -> str:
  49. return "".join(str(s or "").lower().split())
  50. def _farm_matches(lock_farm, farm: str) -> bool:
  51. """锁里的 farm 字段与请求场是否同一场。锁里常写中文全名 (如 '花园口一期(庄源+新风)'),
  52. 请求侧是 slug (如 'huayuankou') → 双向子串 + 别名表。**判不出 = 不匹配** (宁拒勿假过)。"""
  53. lf, f = _norm(lock_farm), _norm(farm)
  54. if not lf or not f:
  55. return False
  56. if lf == f or f in lf or lf in f:
  57. return True
  58. try: # 复用 BF-1 的 slug→中文别名表, 不另维护
  59. sys.path.insert(0, str(_REPO / "scripts"))
  60. from sop_check import FARM_ALIASES # type: ignore
  61. return any(_norm(a) in lf for a in (FARM_ALIASES.get(farm) or ()))
  62. except Exception:
  63. return False
  64. def find_lock(farm: str, root: Path | None = None):
  65. """→ (path, reason)。per 场锁优先; 默认锁**仅在 farm 字段对得上**时才认。"""
  66. root = Path(root or _REPO)
  67. per = P.config(f"analysis_lock_{farm}.yaml")
  68. if per.is_file():
  69. return per, "per 场锁"
  70. dflt = P.config("analysis_lock.yaml")
  71. if dflt.is_file():
  72. import yaml
  73. try:
  74. doc = yaml.safe_load(dflt.read_text(encoding="utf-8")) or {}
  75. except Exception:
  76. return None, "默认锁解析失败"
  77. if _farm_matches(doc.get("farm"), farm):
  78. return dflt, "默认锁 (farm 字段匹配)"
  79. return None, (f"仅有默认锁但它是别场的 (锁内 farm={doc.get('farm')!r} ≠ 请求 {farm!r}) "
  80. f"— **拒绝跨场回落**, 否则等于拿别场的锁给本场放行")
  81. return None, "无任何锁文件"
  82. def assert_frozen(farm: str, *, root=None, strict: bool = True) -> dict:
  83. """ON-0 焊点1 硬闸。返回锁 dict; 不过闸 → 打印响亮说明并 `sys.exit(2)`。
  84. strict=False → 只警告不退出 (仅供**探索性**脚本; 出数入报告的一律 strict)。
  85. 可用环境变量 `ON0_SKIP=1` 临时跳过 —— 会响亮打印"已跳过"留痕 (调试用, 不是常规路径)。
  86. """
  87. root = Path(root or _REPO)
  88. bar = "=" * 72
  89. if os.environ.get("ON0_SKIP") == "1":
  90. print(f"[ON-0] ⚠⚠ ON0_SKIP=1 → **跳过前置锁定门** (farm={farm})。"
  91. f"仅限调试; 出数入报告禁用此路。", file=sys.stderr)
  92. return {}
  93. path, reason = find_lock(farm, root)
  94. if path is None:
  95. msg = (f"\n{bar}\n[ON-0] 🔴 分析前置锁定门未过: farm={farm} — {reason}\n"
  96. f"按 SOP §0.8: **任何分析出数前必先冻结《分析配置单》**, 未冻结不得出数入报告。\n"
  97. f"要做的事: 建 configs/analysis_lock_{farm}.yaml, 冻结六项 (L1 分析窗 / L2 额定装机 /\n"
  98. f" L3 数据源权威表 / L4 指标口径字典 / L5 物理可达基准 / L6 可复现契约),\n"
  99. f" 并由**用户裁决**填 frozen + frozen_by (Claude 自冻不算, §0.8 A 闸)。\n"
  100. f"根因实证: 窗/口径/源/容量/基准未锁 → 同数据两次结果不一 "
  101. f"(利用小时 1330 vs 2389 / 改造前后 +0.3 vs +23.6)。\n{bar}")
  102. if strict:
  103. print(msg, file=sys.stderr)
  104. sys.exit(2)
  105. print(msg + "\n[ON-0] strict=False → 仅警告, 继续 (出数入报告禁用)", file=sys.stderr)
  106. return {}
  107. import yaml
  108. try:
  109. doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
  110. except Exception as e: # 坏 YAML 必响亮 (实测 guojiadian 锁语法就是坏的, 一直没人发现)
  111. print(f"\n{bar}\n[ON-0] 🔴 锁文件 YAML 解析失败: {path.relative_to(root)}\n {type(e).__name__}: {e}\n"
  112. f"锁读不出 = 等于没锁 (§0.8 未冻结不得出数)。修 YAML 后重跑。\n{bar}", file=sys.stderr)
  113. if strict:
  114. sys.exit(2)
  115. return {}
  116. sys.path.insert(0, str(root))
  117. from src.sop import schemas as S
  118. fails = list(S.validate_analysis_lock(doc))
  119. frozen, frozen_by = doc.get("frozen"), str(doc.get("frozen_by") or "")
  120. if not frozen:
  121. fails.append("frozen 空 (未冻结 = 不得出数, §0.8)")
  122. elif "用户裁决" not in frozen_by:
  123. fails.append(f"frozen 有值但 frozen_by={frozen_by!r} 不含『用户裁决』"
  124. f" — Claude 自冻自锁不算权威 (§0.8 A 闸 frozen_by 门)")
  125. if fails:
  126. print(f"\n{bar}\n[ON-0] 🔴 锁自洽闸未过: {path.relative_to(root)} ({len(fails)} 条)", file=sys.stderr)
  127. for f in fails:
  128. print(f" ✗ {f}", file=sys.stderr)
  129. print(f"修锁后重跑。{bar}", file=sys.stderr)
  130. if strict:
  131. sys.exit(2)
  132. return doc
  133. sha = _sha(path)
  134. _RUNS[farm] = {"path": str(path), "sha": sha, "strict": strict}
  135. _trail(root, farm, {"event": "start", "lock": str(path.relative_to(root)),
  136. "sha256": sha, "frozen": str(frozen), "strict": strict})
  137. print(f"[ON-0] ✅ 前置锁定门过 · farm={farm} · {reason} {path.relative_to(root)} · "
  138. f"frozen={frozen} · by={frozen_by[:28]} · sha256:{sha[:16]}")
  139. return doc
  140. def assert_unchanged(farm: str, *, root=None, strict: bool | None = None, stage: str = "出数前") -> bool:
  141. """ON-0 焊点2 = 哈希"结束再核"闭环 (SOP §0.8; 2026-07-29 用户点名收尾)。
  142. 与焊点1 配对: `assert_frozen` 开跑时记锁的 sha256 ("首付"), 本函数在**产物落盘前**重算比对。
  143. 不一致 = 锁在分析过程中被改 → **算出来的数已不对应声明的配置**, 落盘即造成
  144. "报告说按 A 口径算, 锁里写的是 B" 的静默错配。
  145. **放在写产物之前, 不是之后** —— 之后再发现, 错的产物已经在盘上了。
  146. 诚实边界 (承 §9 威胁模型): 本闸防的是**中途改锁** (手滑 / 并发会话改同一锁 / 为让数好看而调锁);
  147. **不防**开跑前就把锁改好, 也不防同时伪造锁与结果 —— 一致性校验证「自洽」非「为真」。
  148. strict=None → 沿用 assert_frozen 当时的 strict (开跑 warn 的别在收尾突然硬杀)。
  149. 返回 True=一致 / False=不一致或无法核 (strict 时不返回, 直接 exit 2)。
  150. """
  151. root = Path(root or _REPO)
  152. bar = "=" * 72
  153. rec = _RUNS.get(farm)
  154. eff_strict = rec["strict"] if (strict is None and rec) else bool(strict)
  155. if os.environ.get("ON0_SKIP") == "1":
  156. print(f"[ON-0] ⚠⚠ ON0_SKIP=1 → 跳过{stage}哈希再核 (farm={farm})", file=sys.stderr)
  157. return True
  158. if not rec:
  159. msg = (f"[ON-0] 🔴 {stage}哈希再核无从进行: farm={farm} 没有首付记录 "
  160. f"(assert_frozen 未调 ∨ 未过闸) — **闭环缺前半**")
  161. print(msg, file=sys.stderr)
  162. _trail(root, farm, {"event": "recheck", "stage": stage, "ok": False, "why": "无首付记录"})
  163. if eff_strict:
  164. sys.exit(2)
  165. return False
  166. p = Path(rec["path"])
  167. if not p.is_file():
  168. print(f"\n{bar}\n[ON-0] 🔴 {stage}哈希再核失败: 锁文件在分析过程中**消失** {p}\n{bar}", file=sys.stderr)
  169. _trail(root, farm, {"event": "recheck", "stage": stage, "ok": False, "why": "锁文件消失"})
  170. if eff_strict:
  171. sys.exit(2)
  172. return False
  173. now = _sha(p)
  174. ok = (now == rec["sha"])
  175. _trail(root, farm, {"event": "recheck", "stage": stage, "lock": str(p.relative_to(root)),
  176. "sha256_start": rec["sha"], "sha256_now": now, "ok": ok})
  177. if ok:
  178. print(f"[ON-0] ✅ {stage}哈希再核一致 · farm={farm} · sha256:{now[:16]} (锁全程未变)")
  179. return True
  180. print(f"\n{bar}\n[ON-0] 🔴 {stage}哈希再核**不一致** — 锁在分析过程中被改!\n"
  181. f" 锁文件: {p.relative_to(root)}\n"
  182. f" 开跑时 sha256: {rec['sha'][:32]}…\n"
  183. f" 现在 sha256: {now[:32]}…\n"
  184. f"⇒ 本次算出的数**已不对应声明的配置**; 落盘会造成『报告说按 A 口径算、锁里是 B』的静默错配。\n"
  185. f"处置二选一: ①锁改是对的 → 用新锁**重跑**分析 ②锁被误改 → 还原锁再跑。\n"
  186. f"(留痕已入 {P.rel(P.sop(farm))}/on0_lock_trail.jsonl)\n{bar}", file=sys.stderr)
  187. if eff_strict:
  188. sys.exit(2)
  189. return False