schema.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. # -*- coding: utf-8 -*-
  2. """对象模型 + 约束校验 (SOP 判读纪律的机器编码).
  3. 对象 = dict(id, type, props, links); 存储 = objects json; 校验 = 写入闸 (validate 不过不落库).
  4. 六条约束 C1-C6 对应双线收敛的判读纪律 — 约束在本体层, 模型(编排层)绕不过.
  5. 2026-08-28 F2 修: C2/C3/C5 从"写在注释里"升为真 raise —
  6. C2 补引用完整性 (evidencedBy 必须指向真实存在的 Evidence 对象, save 全库时查);
  7. C3 从辅助函数升为硬校验 (机制必带 facets+independent_source_count, 数=facet去重数,
  8. 有 evidencedBy 时声明面必须与证据面一致 — 禁按 Evidence 条数冒充源数);
  9. C5 词表补漏 ("在升/上升中/持续升高/持续恶化"), 豁免口径对齐约束本义
  10. (对象 props.claim_window 非空 = 已带窗, 与文本内嵌窗标注等效).
  11. 2026-09-07 C2 v2 (用户令): Verdict 增可选 props.grade (既有六枚举码表 codes.CODES['grade']),
  12. grade=withdrawn 时不要求 evidencedBy, 但必须有 >=1 conflictingWith —— 换极性而非放松,
  13. 故无逃逸口; 其余 grade 与存量无 grade 的 371 条行为不变。反证边与支持边引用完整性同等严格。
  14. 负例见 tests/test_schema_c2_withdrawn.py (正例 2 / 负例 8)。"""
  15. import re
  16. from .codes import CODES, LABELS, normalize as _norm
  17. # ★2026-08-31 枚举层码化: 枚举正本移到 codes.py, 本文件不再持有中文字面量。
  18. # 根因 — 原先枚举定义为中文, 于是**校验闸强制所有生成器写中文**, 英文版残留是必然结果
  19. # 而非疏漏。产物端翻译治标且必然产出混杂串(实测已作废), 故改在闸这一层。
  20. # 过渡: 校验同时接受码与历史中文值(存量 7178 对象 1866 处枚举实测 100% 可映射);
  21. # strict=True 时只认码 — 新生成器走 strict, 存量读取走宽松。
  22. def _enum_ok(field, value, strict=False):
  23. """返回 (是否合法, 归一化后的码)。strict 下历史中文值不合法。"""
  24. if value in CODES.get(field, ()):
  25. return True, value
  26. if strict:
  27. return False, None
  28. c = _norm(field, value)
  29. return (c is not None), c
  30. OBJECT_TYPES = {
  31. 'Turbine', 'Component', 'FailureMechanism', 'Evidence', 'Alarm', 'WorkOrder',
  32. 'Part', 'OilSample', 'Verdict', 'Decision', 'Action', 'MaintenanceWindow', 'LossAccount',
  33. # 检修助手四链 (2026-08-28 用户令): 数据/表现→原理图纸→失效模式部件→检修→WI
  34. 'FailureMode', # 失效模式 (部件级"怎么坏的"; 区别于 FailureMechanism=本场实证机制链)
  35. 'AlarmCode', # 报警码字典 (码→中文→所属系统→处置指引; 抢修链入口)
  36. 'Doc', # 原理/图纸/手册 (可检索的技术依据; props.kind=原理图|电气图|手册|定值|结构树)
  37. 'WorkInstruction', # WI 标准作业指导 (步骤/工具/验收标准/工时; 预防与抢修共用)
  38. 'MaintTask', # 检修任务 (预防性=周期驱动 / 抢修=事件驱动; 连 WI 与失效模式)
  39. 'MaintPlan', # 检修计划 (排程建议: 台×任务×窗口, 按损失/风险排序)
  40. # ── 运维管理体系 (2026-08-31): R1-R4 判据产出与成熟度评估 ──
  41. # 这两类此前由 ingest 直接写 json **绕过 validate** (实测 57 个对象未过闸) —
  42. # 正是"落库≠接上生产路径"的复发。注册 + 定约束 = 把它们拉回闸内。
  43. 'Finding', # 管理判据发现 (R1复发/R2批次/R3闭环/R4记录断层); 必带判据编号与窗
  44. 'Assessment', # 成熟度评估 (维度打分; 字段不存在必须标"不可评估"而非记 0 分)
  45. # ── 运维管理体系骨架 (2026-08-31): 三个维度各成一类参考对象 ──
  46. # 参考数据 (非某台某窗的判断), 所以不受 C5 窗规则约束; 但必须双语成对,
  47. # 否则英文侧渲染出 [translation pending], 而这是要给客户看的骨架。
  48. 'MaintSystem', # 系统分解 (三位作业能力码: 3xx 通用 / 4xx 机型专属)
  49. 'DetectionMethod', # 检出方式 (C15; 10 个受控值, 含 unknown — 见 codes.detection_method)
  50. 'MaintStage', # 运维阶段 (6 个, 各带出口闸)
  51. 'RecordType', # 记录类型 (各带保存期与签署要求)
  52. }
  53. # 关系词表 (links 的 key; 图谱可游走性靠它 — 禁自造关系名, 新关系先进表)
  54. RELATIONS = {
  55. 'about', 'affects', 'basedOn', 'evidencedBy', 'resolves', 'partOf', 'contains',
  56. 'indicates', # 数据/表现/报警 → 失效模式 (征兆指向)
  57. 'explains', # Doc → 部件/失效模式 (原理与图纸解释)
  58. 'mitigatedBy', # 失效模式 → MaintTask (怎么防/怎么修)
  59. 'hasInstruction', # MaintTask → WorkInstruction (标准化作业)
  60. 'causedBy', # 失效模式 → 失效模式 (故障树: 子原因)
  61. 'appliesTo', # WI/Task → Component/Turbine (适用对象)
  62. 'scheduledIn', # MaintPlan → MaintenanceWindow
  63. 'plans', # MaintPlan → MaintTask
  64. # ── 2026-08-28 门禁 I3 对齐: 以下四种在图谱里实际使用但从未声明 ──
  65. # 未声明的关系 = 校验器看不见它, 写错方向或写错目标类型都不会被拦, 属静默风险。
  66. 'of', # 507+ 条: 从属/归属 (OilSample→Turbine, Evidence→Component 等)
  67. 'acceptedBy', # Decision → Acceptance (验收状态机)
  68. 'consumes', # Action → 备件/资源
  69. 'informs', # Evidence/Doc → Decision (支撑决策但非直接证据链)
  70. # ── 2026-09-01 C15 记录体系: 工单的三条归属边 ──
  71. # 根因: 失效模式/告警码/WI/体系骨架全都有 C 条款, **唯独 WorkOrder 零约束**,
  72. # 于是 109 个 FailureMode 与 2113 个 Part 只是躺在库里的孤岛 —— 没有任何
  73. # 规则要求工单去连它们。三条断链不是疏忽, 是约束表上缺了一行。
  74. 'detectedBy', # WorkOrder → DetectionMethod (怎么发现的)
  75. 'hasFailureMode', # WorkOrder → FailureMode (坏成什么样)
  76. 'atSystem', # WorkOrder → MaintSystem (位置码; 原为 prop.ge_system, 应成边)
  77. # 领料复用已有的 consumes (WorkOrder → Part), 不新造关系。
  78. # ── 2026-09-07 否定向边 (用户令; 夜思 2026-09-07 §二.2 逮出) ──
  79. # 根因: 此前 22 个关系**全是支持向**, 且 C2 强制 Verdict 必带 ≥1 evidencedBy —
  80. # 结构上无法表达"这条结论有反证"。活例: 08F 发电机 NDE−DE 阶跃四证俱全被
  81. # 同窗基率闸推翻 (|阶跃|≥2K 占 12/38), 那条反证只活在 SOP 散文与报告里,
  82. # 没落在图上 —— 查 verdict 的 links 仍只看得见当初的支持证据。
  83. # 谓词名取自 PROV-K (Giachelle et al., IRCDL 2025, ceur-ws.org/Vol-3937/paper10.pdf)
  84. # 的 supportedBy/conflictingWith, 非自造 (本表规定新关系优先借有文献出处的名字)。
  85. 'conflictingWith', # Verdict → Evidence (推翻/削弱该判断的证据; 与 evidencedBy 反向)
  86. }
  87. # ★C15 切换日: 此日期**之后**的工单必须带三条归属边; 之前的存量豁免。
  88. # 为什么不追溯: 2403 条存量一条都不满足, 硬卡会让整库过不了 validate; 且历史本来
  89. # 就无法补录 —— 补录的人不是当时在现场的人, 补出来的字段准确率不可信。
  90. WO_CUTOVER = '2026-10-01'
  91. # 声明但当前零使用 (保留而非删除: 排程链是设计中的能力, 删了会丢掉设计意图):
  92. # contains / plans / scheduledIn — 检修计划与作业窗口, 待排程链接入后启用。
  93. # 门禁对"声明未用"只报 WARN 不报 FAIL, 正是为这类"已设计未接线"留位。
  94. # 已声明但主库尚无边的关系 (I4 白名单)。★ 落第一条真边时必须从本表移除, 否则 I4 永远盲。
  95. # conflictingWith 已于 2026-09-07 落第一条真边 (verdict/thermal/WTG08/gen_nde_step
  96. # → evidence/thermal/WTG08/gen_nde_step_baserate), 按本行规矩当场移出白名单。
  97. UNUSED_OK = {'contains', 'plans', 'scheduledIn'}
  98. # 失效模式分类 (ISO 14224 风格; 用于故障树归并与统计)
  99. FM_CLASSES = CODES['fm_class'] # 码; 中文/英文显示查 codes.label('fm_class', c, lang)
  100. # 观测面枚举 (C3: 源数按物理维度去重, 不按证据条数 — "四个观测面一条机制链")
  101. FACETS = CODES['facet']
  102. # 判读三分 (C4: 禁把"不可判"写成阴性)
  103. CROSSCHECK = CODES['crosscheck']
  104. # 动作闸 (C6: 照现有可逆性三级; L3 必须带审级)
  105. REVERSIBILITY = CODES['reversibility']
  106. REVIEW_TIERS = CODES['review_tier']
  107. _PROGRESSIVE = re.compile(r'(恶化中|进行中|正在|持续上升|在升|上升中|持续升高|持续恶化)')
  108. _WINDOWED = re.compile(r'(截至|窗|\d{4}-\d{2})')
  109. class ConstraintError(ValueError):
  110. pass
  111. _WO_DATE = re.compile(r'^\s*(\d{4})[-./年](\d{1,2})[-./月](\d{1,2})')
  112. def _wo_date(v):
  113. """工单日期 → ISO 'YYYY-MM-DD'; 解析不出返回 None (响亮地按存量处理, 不猜)。
  114. 存量实测四种形态: ISO 短横 4276 / 其他 13 / 空 7 / 点分隔 2。
  115. **不可拿原串比字典序** —— '.' (0x2E) > '-' (0x2D), 于是 '2026.6.21' >= '2026-10-01'
  116. 成立, 旧记录会假冒成切换日之后而被闸误拦。首版就是这么错的。
  117. """
  118. if not v:
  119. return None
  120. m = _WO_DATE.match(str(v))
  121. return '%s-%02d-%02d' % (m.group(1), int(m.group(2)), int(m.group(3))) if m else None
  122. def _req(obj, path, msg):
  123. cur = obj
  124. for k in path.split('.'):
  125. if not isinstance(cur, dict) or k not in cur or cur[k] in (None, '', []):
  126. raise ConstraintError(f"[{obj.get('id','?')}] {msg} (缺 {path})")
  127. cur = cur[k]
  128. return cur
  129. def validate(obj, registry=None, strict=False):
  130. """写入闸: 不过即 raise (判据必需输入禁容错取值的本体版).
  131. registry: 全库 dict(id→obj); 传入时追加引用完整性/跨对象一致性校验 (save 全库时传).
  132. strict: True 时枚举只认 codes.py 的码 (新生成器用), False 兼容存量中文值 (读旧库用)."""
  133. _req(obj, 'id', '对象必须有 id')
  134. t = _req(obj, 'type', '对象必须有 type')
  135. if t not in OBJECT_TYPES:
  136. raise ConstraintError(f"[{obj['id']}] 未知对象类型 {t}")
  137. props = obj.get('props', {})
  138. # ---- 检修助手四链约束 (C7-C10) ----
  139. if t == 'FailureMode':
  140. _req(obj, 'props.name', 'C7: 失效模式必带 name')
  141. cls = _req(obj, 'props.fm_class', 'C7: 失效模式必带 fm_class (ISO14224 风格分类)')
  142. ok, _ = _enum_ok('fm_class', cls, strict)
  143. if not ok:
  144. raise ConstraintError(f"[{obj['id']}] C7: fm_class '{cls}' 不在码表 {sorted(FM_CLASSES)}")
  145. if not obj.get('links', {}).get('affects'):
  146. raise ConstraintError(f"[{obj['id']}] C7: 失效模式必须 affects 至少一个部件 — 不挂部件的模式无法进检修链")
  147. if t == 'AlarmCode':
  148. _req(obj, 'props.code', 'C8: 报警码必带 code')
  149. _req(obj, 'props.source', 'C8: 报警码必带 source (码表出处 — 码义靠猜会翻案)')
  150. if t == 'WorkInstruction':
  151. _req(obj, 'props.steps', 'C9: WI 必带 steps (无步骤的"指导书"不可执行)')
  152. _req(obj, 'props.source', 'C9: WI 必带 source (手册/作业指导书出处, 便于回溯原文)')
  153. if t == 'MaintTask':
  154. tk = _req(obj, 'props.task_kind', 'C10: 检修任务必带 task_kind (预防性/抢修/检查)')
  155. if not _enum_ok('task_kind', tk, strict)[0]:
  156. raise ConstraintError(f"[{obj['id']}] C10: task_kind '{tk}' 非法")
  157. if tk == '预防性' and not props.get('interval'):
  158. raise ConstraintError(f"[{obj['id']}] C10: 预防性任务必带 interval (周期; 无周期=不可排程)")
  159. if t == 'MaintPlan':
  160. _req(obj, 'props.rank_basis', 'C11: 排程计划必带 rank_basis (按什么排序 — 损失/风险须显式)')
  161. # ---- C12/C13: 运维管理体系约束 (每条都由如东实证倒逼, 非凭空设计) ----
  162. if t in ('MaintSystem', 'MaintStage', 'RecordType'):
  163. # C14: 体系骨架必须双语成对 —— 这三类是给客户看的框架, 缺一侧就会在
  164. # 英文界面上渲染成 [translation pending], 等于框架有洞。
  165. for k in ('name_zh', 'name_en'):
  166. _req(obj, f'props.{k}', f'C14: 体系对象必带 {k} (双语成对, 不许单写)')
  167. if t == 'MaintSystem' and props.get('tier') not in ('basic', 'eng', 'common', 'platform'):
  168. raise ConstraintError(f"[{obj['id']}] C14: MaintSystem 必带 tier "
  169. f"∈ basic/eng/common/platform (通用与机型专属的分界是这套体系的用处所在)")
  170. if t == 'MaintStage':
  171. _req(obj, 'props.gate_zh', 'C14: 阶段必带出口闸 — 无闸的阶段划分只是标签')
  172. _req(obj, 'props.gate_en', 'C14: 阶段必带出口闸 (英文侧)')
  173. if t == 'RecordType':
  174. _req(obj, 'props.retention', 'C14: 记录类型必带保存期')
  175. # ---- C15: 工单记录体系 (2026-09-01) ----
  176. if t == 'DetectionMethod':
  177. for k in ('name_zh', 'name_en'):
  178. _req(obj, f'props.{k}', f'C15: 检出方式必带 {k} (双语成对)')
  179. c = _req(obj, 'props.code', 'C15: 检出方式必带 code')
  180. if not _enum_ok('detection_method', c, strict)[0]:
  181. raise ConstraintError(f"[{obj['id']}] C15: detection_method '{c}' 不在码表")
  182. if t == 'WorkOrder':
  183. # ★口径一: type='WorkOrder' 覆盖四个 id 家族 (workorder 2403 / dailyreport 1811 /
  184. # faultrecord 64 / majorrepair 20)。**检修日报是班次记录不是失效记录**, 且场级日报
  185. # 按设计就没有单台机组 (实测 25 条 dailyreport/fleet/*), 故 C15 只管失效记录家族。
  186. if obj['id'].split('/')[0] in ('workorder', 'faultrecord', 'majorrepair'):
  187. d = _wo_date(props.get('date'))
  188. # ★口径二: 切换日之前的存量豁免 (理由见 WO_CUTOVER)。日期解析不出来的一律按存量 —
  189. # 新记录出自带日期选择器的新模板, 不可能没有日期或写成非 ISO 格式。
  190. # (首版直接拿原串比字典序, 被 '2026.6.21' 骗过去 —— '.' > '-', 旧记录假冒成
  191. # 切换日之后。日期比较前必须归一, 不能拿原串比。)
  192. if d and d >= WO_CUTOVER:
  193. _req(obj, 'props.turbine', 'C15: 切换日起失效记录必带 turbine')
  194. lk = obj.get('links') or {}
  195. for rel, why in (
  196. ('atSystem', '位置码必填 (75 个 RDS-PP 下拉)'),
  197. ('hasFailureMode', '失效模式必填 (109 个下拉)'),
  198. ('detectedBy', '检出方式必填 — 含"不确定", 但不许留空')):
  199. if not lk.get(rel):
  200. raise ConstraintError(
  201. f"[{obj['id']}] C15: {WO_CUTOVER} 起工单必带 links.{rel} — {why}")
  202. if t == 'Finding':
  203. cr = _req(obj, 'props.criterion', 'C12: 管理发现必带 criterion (R1-R4 哪条判据)')
  204. if cr not in ('R1', 'R2', 'R3', 'R4'):
  205. raise ConstraintError(f"[{obj['id']}] C12: criterion '{cr}' 非法 (应 ∈ R1/R2/R3/R4)")
  206. _req(obj, 'props.claim_window', 'C12: 管理发现必带 claim_window (台账窗外不背书)')
  207. # ★如东实证: 工单"条数"≠作业"次数" — 同日重复条目使计数虚高 1.18×, WTG31 STIC 6条=3次。
  208. # 凡以计数支撑的发现, 必须声明用的是哪个口径, 否则数字不可复现。
  209. if props.get('count') is not None and not props.get('count_basis'):
  210. raise ConstraintError(f"[{obj['id']}] C12: 带 count 必须声明 count_basis "
  211. f"(work_orders=工单条数 / work_sessions=作业次数; 如东实测两者差 1.18×)")
  212. g = props.get('grade')
  213. if g is not None and not _enum_ok('grade', g, strict)[0]:
  214. raise ConstraintError(f"[{obj['id']}] C12: grade '{g}' 不在码表 {sorted(CODES['grade'])}")
  215. # ★如东实证: 螺栓复发因无"螺栓位置"字段而无法区分同位复发/异位各一次;
  216. # IGBT 批次与记录制度变更同年 — 两条都必须随发现一起走, 否则下游读成硬结论。
  217. if props.get('caveat') in ('', [], {}):
  218. raise ConstraintError(f"[{obj['id']}] C12: caveat 存在但为空 — 无保留写 null, 别留空壳")
  219. # C12b: 光有 count_basis 不够 —— 取值必须在码表里。
  220. # 只查"有没有"会让字段被填成手边最近的词 (2026-08-31 实测 3/5 条错标),
  221. # 那反而更危险: 下游看到字段有值, 以为口径已声明。
  222. if props.get('count_basis') and not _enum_ok('count_basis', props['count_basis'], True):
  223. raise ConstraintError(
  224. f"[{obj['id']}] C12b: count_basis='{props['count_basis']}' 不在码表 —— "
  225. f"count={props.get('count')} 数的到底是什么? 从 codes.LABELS['count_basis'] 里选")
  226. if t == 'Assessment':
  227. dims = _req(obj, 'props.dimensions', 'C13: 评估必带 dimensions (逐维结果)')
  228. if not isinstance(dims, dict):
  229. raise ConstraintError(f"[{obj['id']}] C13: dimensions 必须是 dict(维度→结果)")
  230. # ★如东实证: 首版把"字段不存在"当成"工作没做"记 0 分, 得 2.6/100 —— 那是判据错不是现场差。
  231. # 每个维度必须显式声明可评估性; 不可评估的维度不进分母。
  232. for k, v in dims.items():
  233. if not isinstance(v, dict) or 'evaluable' not in v:
  234. raise ConstraintError(f"[{obj['id']}] C13: 维度 '{k}' 必带 evaluable "
  235. f"(数据字段不存在 ⇒ 不可评估, 不是 0 分)")
  236. if v['evaluable'] and v.get('score') is None:
  237. raise ConstraintError(f"[{obj['id']}] C13: 维度 '{k}' 声明可评估却无 score")
  238. _req(obj, 'props.evaluable_dims', 'C13: 必带 evaluable_dims (分母 — 总分只在可评估维上归一)')
  239. if t == 'Evidence':
  240. _req(obj, 'props.claim_window', 'C1: Evidence 必带 claim_window (证据窗外该证据不背书)')
  241. _req(obj, 'props.source', 'C1: Evidence 必带 source (数据源身份/产物路径)')
  242. facet = _req(obj, 'props.facet', 'C1: Evidence 必带 facet (观测面)')
  243. ok, _ = _enum_ok('facet', facet, strict)
  244. if not ok:
  245. raise ConstraintError(f"[{obj['id']}] C1: facet '{facet}' 不在码表 {sorted(FACETS)}")
  246. cc = props.get('crosscheck')
  247. if cc is not None and not _enum_ok('crosscheck', cc, strict)[0]:
  248. raise ConstraintError(f"[{obj['id']}] C4: crosscheck '{cc}' 不在三分枚举 {sorted(CROSSCHECK)} "
  249. f"(禁把'不可判'写成阴性/排除)")
  250. if t == 'Decision':
  251. # ---- C16 (2026-09-07; 审逮 "schema 对 Decision 零约束"): 最小约束, 不破坏存量 33 条 (全有 name; status/kind/closure_type 存量为空则不查) ----
  252. _req(obj, 'props.name', 'C16: Decision 必带 name (裁了什么)')
  253. for fld, key in (('closure_type', 'closure_type'), ('status', 'decision_status'), ('kind', 'decision_kind')):
  254. v = props.get(fld)
  255. if v is not None and not _enum_ok(key, v, strict)[0]:
  256. raise ConstraintError(f"[{obj['id']}] C16: Decision.{fld} '{v}' 不在码表 {sorted(CODES.get(key, ()))}")
  257. if props.get('kind') in ('closure_proposal', 'closure_migrated'):
  258. if not props.get('closure_type'): raise ConstraintError(f"[{obj['id']}] C16: 归宿类 Decision 必带 closure_type")
  259. if not obj.get('links', {}).get('basedOn'): raise ConstraintError(f"[{obj['id']}] C16: 归宿类 Decision 必 basedOn 指向 Verdict")
  260. if t == 'Verdict':
  261. _req(obj, 'props.review_tier', 'C2: Verdict 必带审级')
  262. if not _enum_ok('review_tier', props['review_tier'], strict)[0]:
  263. raise ConstraintError(f"[{obj['id']}] C2: 审级 '{props['review_tier']}' 不在 {sorted(REVIEW_TIERS)}")
  264. # ── C2 v2 (2026-09-07 用户令 "改 C2, 允许撤回态 Verdict 只挂反证") ──
  265. # 原 C2 只认支持向, 于是被推翻的结论只有两条路: 保留一条支持证据 (等于仍在主张)
  266. # 或整条删掉 (等于历史消失) —— "已撤回但保留证据链"结构上不存在。
  267. # 改法不是**放松**证据要求, 是**换极性**: 撤回态照样必须有据, 只是那据是反证。
  268. # 故 C2 无逃逸口 —— 任何 Verdict 都必须至少挂一条某极性的 Evidence。
  269. # grade 用既有六枚举码表 (codes.CODES['grade'], = SOP 六枚举), 不新造枚举;
  270. # 不复用 props.level: level 是 L1 判级 (优秀/良好/报警…), 且被 audit 第5项拿去与
  271. # 实时 L1 对拍, 借位会把两个语义压进一个字段。
  272. grade = props.get('grade')
  273. if grade is None:
  274. gcode = None # 存量 371 条无 grade → 走原路径, 不破坏
  275. else:
  276. ok, gcode = _enum_ok('grade', grade, strict)
  277. if not ok:
  278. raise ConstraintError(f"[{obj['id']}] C2: Verdict.grade '{grade}' 不在六枚举 "
  279. f"{sorted(CODES.get('grade', ()))}")
  280. links = obj.get('links', {})
  281. ev = links.get('evidencedBy', [])
  282. cw = links.get('conflictingWith', [])
  283. if gcode == 'withdrawn':
  284. # 撤回必须有据: 无反证的"撤回"= 静默删除, 事后无从复核为什么撤。
  285. if not cw:
  286. raise ConstraintError(
  287. f"[{obj['id']}] C2: 撤回态 Verdict 必须链接 >=1 条 conflictingWith Evidence "
  288. f"(撤回须有据 — 无据的撤回 = 静默删除, 事后无从复核)")
  289. elif not ev:
  290. raise ConstraintError(f"[{obj['id']}] C2: Verdict 必须链接 ≥1 条 Evidence")
  291. _check_evidence_refs(obj, ev, registry)
  292. _check_evidence_refs(obj, cw, registry, rel='conflictingWith')
  293. if t == 'FailureMechanism':
  294. # C3 硬校验: 独立源数 = 观测面去重数 (禁按 Evidence 条数计源)
  295. facets = _req(obj, 'props.facets', 'C3: 机制必带 facets 观测面列表')
  296. bad = [f for f in facets if not _enum_ok('facet', f, strict)[0]]
  297. if bad:
  298. raise ConstraintError(f"[{obj['id']}] C3: facets 含未知观测面 {bad} (枚举 {sorted(FACETS)})")
  299. if len(facets) != len(set(facets)):
  300. raise ConstraintError(f"[{obj['id']}] C3: facets 含重复面 {facets} — 同面多证据≠多面")
  301. isc = props.get('independent_source_count')
  302. if not isinstance(isc, int):
  303. raise ConstraintError(f"[{obj['id']}] C3: 机制必带 independent_source_count (int, =facet 去重数; "
  304. f"禁按 Evidence 条数计源)")
  305. if isc != len(set(facets)):
  306. raise ConstraintError(f"[{obj['id']}] C3: independent_source_count={isc} ≠ facet 去重数 "
  307. f"{len(set(facets))} ({sorted(set(facets))})")
  308. ev = obj.get('links', {}).get('evidencedBy', [])
  309. _check_evidence_refs(obj, ev, registry)
  310. if registry is not None and ev:
  311. ev_facets = {_norm('facet', registry[x]['props']['facet']) for x in ev if x in registry}
  312. if ev_facets != {_norm('facet', f) for f in facets}: # 码空间比较, 免中文/码混存时假不等
  313. raise ConstraintError(f"[{obj['id']}] C3: 声明 facets {sorted(set(facets))} ≠ "
  314. f"证据实际观测面 {sorted(ev_facets)} — 声明面必须由证据支撑")
  315. if t == 'Action':
  316. _req(obj, 'props.gate', 'C6: Action 必带 gate')
  317. g = props['gate']
  318. if not _enum_ok('reversibility', g.get('reversibility'), strict)[0]:
  319. raise ConstraintError(f"[{obj['id']}] C6: gate.reversibility 必须 ∈ {sorted(REVERSIBILITY)}")
  320. if _norm('reversibility', g['reversibility']) == 'L3_approval' \
  321. and not _enum_ok('review_tier', g.get('review_tier'), strict)[0]:
  322. raise ConstraintError(f"[{obj['id']}] C6: L3 动作必须带审级 (建议无界, 动作受闸)")
  323. # C5: 状态措辞窗规则 — 进行时态措辞必须带窗:
  324. # 对象 props.claim_window 非空 = 整对象已声明窗 (约束本义); 否则逐字符串扫内嵌窗标注.
  325. # ★适用域 = 产判级/下结论的类型。字典类 (AlarmCode/Doc/WorkInstruction/MaintTask) 是**第三方词条转录**,
  326. # "INU错误:发电机正在励磁" 是码表原文的机制定性, 不是我方对某台某窗的状态断言 —
  327. # 对它套 C5 等于要求给厂商字典加窗, 属闸的适用域错配 (2026-08-28 kb_ingest 实逮)。
  328. _C5_SCOPE = {'Evidence', 'Verdict', 'Decision', 'FailureMechanism', 'FailureMode', 'Action', 'MaintPlan'}
  329. if t in _C5_SCOPE and not props.get('claim_window'):
  330. def _scan(v, key):
  331. if isinstance(v, str) and _PROGRESSIVE.search(v) and not _WINDOWED.search(v):
  332. raise ConstraintError(f"[{obj['id']}] C5: '{key}' 含进行时态措辞而无窗标注 "
  333. f"(机制定性窗无关可长期挂; 状态判断窗相关必带窗): {v[:60]}")
  334. if isinstance(v, dict):
  335. for k2, v2 in v.items(): _scan(v2, f'{key}.{k2}')
  336. if isinstance(v, list):
  337. for i, v2 in enumerate(v): _scan(v2, f'{key}[{i}]')
  338. _scan(props, 'props')
  339. return obj
  340. def _check_evidence_refs(obj, ev_ids, registry, rel='evidencedBy'):
  341. """C2 引用完整性: evidencedBy / conflictingWith 必须指向真实存在的 Evidence 对象.
  342. rel 只影响报错文案; 两个极性同等严格 —— 反证挂悬空 id 或非 Evidence 对象与支持证据
  343. 挂错一样是硬错, 否则"撤回须有据"可以用一条假 id 糊弄过去。"""
  344. if registry is None:
  345. return
  346. for x in ev_ids:
  347. if x not in registry:
  348. raise ConstraintError(f"[{obj['id']}] C2: {rel} 指向不存在的对象 {x} (悬空引用)")
  349. if registry[x]['type'] != 'Evidence':
  350. raise ConstraintError(f"[{obj['id']}] C2: {rel} 指向非 Evidence 对象 {x} "
  351. f"(type={registry[x]['type']})")
  352. def facet_count(evidence_objs):
  353. """C3: 机制的独立源数 = 观测面去重数, 禁按 Evidence 条数 (同一失效机制的多观测面≠多源印证)."""
  354. return len({e['props']['facet'] for e in evidence_objs})