schema_guard.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """schema_guard.py — A1 schema铁律 + A2 落盘自证 的**机器位** (削减读取/落盘侧幻觉).
  2. 背景 (memory `tool-hallucination-git-authoritative`): 工具不稳时"读文件成功"的整段输出可能是
  3. 幻觉 (海勒斯 2026-07-15: 假报 turbine_10min.parquet shape=60480×65+中文列, 真只有 turbine_sec.parquet
  4. 英文列)。本模块把"读前必核 + 落盘自证"从纪律变成**函数**——分析码调 verified_read 而非裸 pd.read_*,
  5. schema铁律 by construction; 落盘后调 attest 拿 sha256 自证。
  6. - A1 verified_read: 存在断言(响亮列出真目录) → 真读拿真列名/shape → require_cols 逐列 assert。
  7. - A2 attest: 落盘/引用文件返回 {sha256, size, rows} 内容寻址自证 (对齐 CL-2 fingerprint 的 sha256, 非md5)。
  8. - 权威源只认: Path.exists()退出码 + 真读到的 df.columns + sha256内容哈希 + git ls-files (不信"读成功"回执)。
  9. 用法:
  10. from src.sop.schema_guard import verified_read, assert_cols, attest
  11. df, man = verified_read("outputs/x/cleaned/t.parquet", require_cols=["active_power","风速"])
  12. # ... 落盘后 ...
  13. print(attest("outputs/x/sop/findings.json")) # {'exists':True,'sha256':'...','size':...}
  14. """
  15. from __future__ import annotations
  16. import hashlib
  17. import pathlib
  18. import subprocess
  19. __all__ = ["sha256", "git_tracked", "attest", "verified_read", "assert_cols", "SchemaGuardError",
  20. "three_source_aligned", "three_source_lint", "THREE_SOURCE_NOTE",
  21. "INDEPENDENCE_LEVELS", "independence_lint"]
  22. # B5 温度候选独立性分级 (正本定义: outputs/_meta/温度judgment外部验证率_元审计_2026-07-15.md §独立性分级)
  23. # 关键戒: 同组多手段 ≠ 第三方独立 (memory scada-residual / anchor-validation)。
  24. INDEPENDENCE_LEVELS = {
  25. "L0": "纯温度自证 — 只由该部件温度NBM残差判定, 无任何非温度信号",
  26. "L1": "同域多通道互证 (swap配对/DE−油温差/六相绕组/多通道同热) — 仍温度, 抓接线/传感器有效, 但非外部真值(冷却系统级偏差会多通道齐偏)",
  27. "L2": "跨域独立信号 (振动/CMS/油样金属屑/事件故障日志/现场取振开箱) — 唯一破循环的外部锚; L2命中还须超几何期望+组间检验(base-rate)",
  28. }
  29. # A3 三源对齐: 任一 claim 须 (源文件真读 × 独立重跑 × 内容哈希) 三对齐, 单源 → 应 INSUFFICIENT。
  30. # 承载约定 (SOP字段): finding.provenance{src_doc} (源) + finding.reproduced[{claim,cmd}] (独立重跑)
  31. # + finding.basis.attest{sha256,path} (内容哈希, 用 attest() 生成)。前两腿已在 schemas.validate_review;
  32. # 本模块补第三腿(哈希)并提供 report-ship 交付前 lint (不接 always-on sop_check, 防炸存量场)。
  33. THREE_SOURCE_NOTE = "A3: claim 须 源真读×独立重跑×内容哈希 三对齐; 承载 = provenance.src_doc + reproduced[] + basis.attest.sha256"
  34. class SchemaGuardError(AssertionError):
  35. """读前核 / 列断言失败 = 响亮 (不静默继续用幻觉 schema)。"""
  36. def sha256(path: str | pathlib.Path, buf: int = 1 << 20) -> str:
  37. """内容哈希 (对齐 contract_gate._sha256 的 CL-2 指纹约定)。"""
  38. h = hashlib.sha256()
  39. with open(path, "rb") as f:
  40. while chunk := f.read(buf):
  41. h.update(chunk)
  42. return h.hexdigest()
  43. def git_tracked(path: str | pathlib.Path) -> bool:
  44. """git ls-files 交叉核: 文件是否已被 git 追踪 (A1 '真读到' 的第二道)。"""
  45. p = pathlib.Path(path)
  46. try:
  47. r = subprocess.run(["git", "ls-files", "--error-unmatch", str(p)],
  48. capture_output=True, text=True, cwd=p.resolve().parent)
  49. return r.returncode == 0
  50. except Exception:
  51. return False
  52. def attest(path: str | pathlib.Path, rows: bool = True) -> dict:
  53. """A2 落盘/引用自证: 返回内容寻址证据。不存在 = 响亮 (exists=False, 不假装)。"""
  54. p = pathlib.Path(path)
  55. if not p.exists():
  56. return {"path": str(p), "exists": False, "note": "文件不存在 — 任何'已落盘'声称为幻觉"}
  57. out = {"path": str(p), "exists": True, "sha256": sha256(p),
  58. "size": p.stat().st_size, "tracked": git_tracked(p)}
  59. if rows and p.suffix.lower() in (".parquet", ".csv"):
  60. try:
  61. import pandas as pd
  62. d = pd.read_parquet(p) if p.suffix.lower() == ".parquet" else pd.read_csv(p, nrows=None)
  63. out["rows"] = int(len(d))
  64. out["cols"] = int(d.shape[1])
  65. except Exception as e:
  66. out["rows_error"] = str(e)[:120]
  67. return out
  68. def assert_cols(df, cols: list[str], where: str = "") -> None:
  69. """A1 列断言: 逐列 assert 在 df.columns; 缺 = 响亮列出**真有的**列 (禁用幻觉列名)。"""
  70. have = list(df.columns)
  71. missing = [c for c in cols if c not in have]
  72. if missing:
  73. raise SchemaGuardError(
  74. f"schema铁律 [{where or '?'}]: 需要列缺失 {missing}。"
  75. f" 真实列 ({len(have)}): {have[:40]}{' …' if len(have) > 40 else ''}")
  76. def verified_read(path: str | pathlib.Path, require_cols: list[str] | None = None,
  77. columns: list[str] | None = None, kind: str = "auto") -> tuple:
  78. """A1 读前必核: 存在断言 → 真读 → require_cols 逐列 assert。返回 (df, manifest)。
  79. 幻觉防线: 文件不存在 → 响亮 FileNotFoundError + 列出**真实目录内容** (海勒斯型:
  80. 你以为读 turbine_10min, 真目录只有 turbine_sec → 立刻现形, 不会假读出 schema)。
  81. columns: 只载这些列 (效率); 断言先于载数据 (parquet 读真 schema 名核, 子集读不失断言)。
  82. """
  83. p = pathlib.Path(path)
  84. if not p.exists():
  85. sibs = sorted(x.name for x in p.parent.iterdir()) if p.parent.exists() else ["<父目录也不存在>"]
  86. raise FileNotFoundError(
  87. f"schema铁律: {p} 不存在。真实同目录内容: {sibs} (禁信'读成功'—先ls核真名)")
  88. import pandas as pd
  89. k = kind
  90. if k == "auto":
  91. k = "parquet" if p.suffix.lower() == ".parquet" else "csv"
  92. need = list(dict.fromkeys((require_cols or []) + (columns or [])))
  93. if k == "parquet":
  94. import pyarrow.parquet as _pq
  95. real_names = list(_pq.ParquetFile(str(p)).schema.names) # 真 schema, 不载数据
  96. if need: # 断言先于载 (子集读也核)
  97. miss = [c for c in need if c not in real_names]
  98. if miss:
  99. raise SchemaGuardError(f"schema铁律 [{p.name}]: 需要列缺失 {miss}。"
  100. f" 真实列 ({len(real_names)}): {real_names[:40]}")
  101. df = pd.read_parquet(p, columns=columns) if columns else pd.read_parquet(p)
  102. ncols_total = len(real_names)
  103. else:
  104. df = pd.read_csv(p, usecols=columns) if columns else pd.read_csv(p)
  105. ncols_total = df.shape[1]
  106. if require_cols:
  107. assert_cols(df, require_cols, where=p.name)
  108. man = {"path": str(p), "sha256": sha256(p), "rows": int(len(df)),
  109. "cols": int(ncols_total), "cols_real": list(df.columns), "tracked": git_tracked(p)}
  110. return df, man
  111. def three_source_aligned(finding: dict) -> bool:
  112. """A3: (源真读 × 独立重跑 × 内容哈希) 三对齐? 单源/缺腿 → False (该 claim 应 INSUFFICIENT)。"""
  113. prov = finding.get("provenance") or {}
  114. if isinstance(prov, list): # provenance 可为条目列表 (schemas.validate_review 口径)
  115. prov = prov[0] if prov else {}
  116. basis = finding.get("basis") or {}
  117. has_source = bool(prov.get("src_doc") or prov.get("source_read"))
  118. rep = finding.get("reproduced")
  119. has_rerun = bool(rep and all(isinstance(r, dict) and r.get("claim") and r.get("cmd") for r in rep)) \
  120. or bool(prov.get("independent_rerun"))
  121. has_hash = bool((basis.get("attest") or {}).get("sha256") or prov.get("content_hash"))
  122. return has_source and has_rerun and has_hash
  123. def three_source_lint(findings, high_tiers=("定论", "准定论")) -> list[dict]:
  124. """A3 交付前 lint: 高阶结论(定论/准定论族)但三源未对齐 → flag(应降 INSUFFICIENT)。
  125. report-ship §6 用; 返回 [{id, verdict, missing}]。空 = 全部三源对齐或全低阶。"""
  126. out = []
  127. for it in findings or []:
  128. v = str(it.get("verdict", ""))
  129. if any(t in v for t in high_tiers) and not three_source_aligned(it):
  130. prov = it.get("provenance") or {}
  131. prov = (prov[0] if prov else {}) if isinstance(prov, list) else prov
  132. basis = it.get("basis") or {}
  133. missing = []
  134. if not (prov.get("src_doc") or prov.get("source_read")):
  135. missing.append("源真读(provenance.src_doc)")
  136. rep = it.get("reproduced")
  137. if not (rep and all(isinstance(r, dict) and r.get("claim") and r.get("cmd") for r in rep)):
  138. missing.append("独立重跑(reproduced[claim,cmd])")
  139. if not ((basis.get("attest") or {}).get("sha256") or prov.get("content_hash")):
  140. missing.append("内容哈希(basis.attest.sha256)")
  141. out.append({"id": it.get("id"), "verdict": v, "missing": missing,
  142. "issue": "A3 三源未对齐 → 应降 INSUFFICIENT"})
  143. return out
  144. def independence_lint(findings, require_ids=None, high_tiers=("定论", "准定论")) -> list[dict]:
  145. """B5 温度候选独立性 lint (报告/交付前调)。flag 三类:
  146. ① independence_level 非法 (∉ L0/L1/L2);
  147. ② L0/L1(未跨域外部验证) 但结论高阶(定论/准定论族) 且未显式标 external_unvalidated → 须标或降级;
  148. ③ require_ids 指定的温度域候选缺 independence_level (强制带分级)。
  149. 返回 [{id, issue, ...}]; 空 = 全合规。"""
  150. out = []
  151. req = set(require_ids or [])
  152. for it in findings or []:
  153. fid = it.get("id")
  154. lvl = it.get("independence_level")
  155. v = str(it.get("verdict", ""))
  156. if fid in req and not lvl:
  157. out.append({"id": fid, "issue": "温度域候选缺 independence_level (须标 L0/L1/L2)"})
  158. continue
  159. if lvl is not None and lvl not in INDEPENDENCE_LEVELS:
  160. out.append({"id": fid, "level": lvl,
  161. "issue": f"independence_level '{lvl}' ∉ {list(INDEPENDENCE_LEVELS)}"})
  162. continue
  163. if lvl in ("L0", "L1") and any(t in v for t in high_tiers) and not it.get("external_unvalidated"):
  164. out.append({"id": fid, "level": lvl, "verdict": v,
  165. "issue": f"{lvl}(未跨域外部验证) 但结论 {v} → 须显式标 external_unvalidated=True "
  166. f"或降级 (B5: 同域多通道≠外部真值, L2才破循环)"})
  167. return out
  168. if __name__ == "__main__":
  169. import json
  170. import sys
  171. if len(sys.argv) > 1:
  172. print(json.dumps(attest(sys.argv[1]), ensure_ascii=False, indent=1))