| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392 |
- # -*- coding: utf-8 -*-
- """对象模型 + 约束校验 (SOP 判读纪律的机器编码).
- 对象 = dict(id, type, props, links); 存储 = objects json; 校验 = 写入闸 (validate 不过不落库).
- 六条约束 C1-C6 对应双线收敛的判读纪律 — 约束在本体层, 模型(编排层)绕不过.
- 2026-08-28 F2 修: C2/C3/C5 从"写在注释里"升为真 raise —
- C2 补引用完整性 (evidencedBy 必须指向真实存在的 Evidence 对象, save 全库时查);
- C3 从辅助函数升为硬校验 (机制必带 facets+independent_source_count, 数=facet去重数,
- 有 evidencedBy 时声明面必须与证据面一致 — 禁按 Evidence 条数冒充源数);
- C5 词表补漏 ("在升/上升中/持续升高/持续恶化"), 豁免口径对齐约束本义
- (对象 props.claim_window 非空 = 已带窗, 与文本内嵌窗标注等效).
- 2026-09-07 C2 v2 (用户令): Verdict 增可选 props.grade (既有六枚举码表 codes.CODES['grade']),
- grade=withdrawn 时不要求 evidencedBy, 但必须有 >=1 conflictingWith —— 换极性而非放松,
- 故无逃逸口; 其余 grade 与存量无 grade 的 371 条行为不变。反证边与支持边引用完整性同等严格。
- 负例见 tests/test_schema_c2_withdrawn.py (正例 2 / 负例 8)。"""
- import re
- from .codes import CODES, LABELS, normalize as _norm
- # ★2026-08-31 枚举层码化: 枚举正本移到 codes.py, 本文件不再持有中文字面量。
- # 根因 — 原先枚举定义为中文, 于是**校验闸强制所有生成器写中文**, 英文版残留是必然结果
- # 而非疏漏。产物端翻译治标且必然产出混杂串(实测已作废), 故改在闸这一层。
- # 过渡: 校验同时接受码与历史中文值(存量 7178 对象 1866 处枚举实测 100% 可映射);
- # strict=True 时只认码 — 新生成器走 strict, 存量读取走宽松。
- def _enum_ok(field, value, strict=False):
- """返回 (是否合法, 归一化后的码)。strict 下历史中文值不合法。"""
- if value in CODES.get(field, ()):
- return True, value
- if strict:
- return False, None
- c = _norm(field, value)
- return (c is not None), c
- OBJECT_TYPES = {
- 'Turbine', 'Component', 'FailureMechanism', 'Evidence', 'Alarm', 'WorkOrder',
- 'Part', 'OilSample', 'Verdict', 'Decision', 'Action', 'MaintenanceWindow', 'LossAccount',
- # 检修助手四链 (2026-08-28 用户令): 数据/表现→原理图纸→失效模式部件→检修→WI
- 'FailureMode', # 失效模式 (部件级"怎么坏的"; 区别于 FailureMechanism=本场实证机制链)
- 'AlarmCode', # 报警码字典 (码→中文→所属系统→处置指引; 抢修链入口)
- 'Doc', # 原理/图纸/手册 (可检索的技术依据; props.kind=原理图|电气图|手册|定值|结构树)
- 'WorkInstruction', # WI 标准作业指导 (步骤/工具/验收标准/工时; 预防与抢修共用)
- 'MaintTask', # 检修任务 (预防性=周期驱动 / 抢修=事件驱动; 连 WI 与失效模式)
- 'MaintPlan', # 检修计划 (排程建议: 台×任务×窗口, 按损失/风险排序)
- # ── 运维管理体系 (2026-08-31): R1-R4 判据产出与成熟度评估 ──
- # 这两类此前由 ingest 直接写 json **绕过 validate** (实测 57 个对象未过闸) —
- # 正是"落库≠接上生产路径"的复发。注册 + 定约束 = 把它们拉回闸内。
- 'Finding', # 管理判据发现 (R1复发/R2批次/R3闭环/R4记录断层); 必带判据编号与窗
- 'Assessment', # 成熟度评估 (维度打分; 字段不存在必须标"不可评估"而非记 0 分)
- # ── 运维管理体系骨架 (2026-08-31): 三个维度各成一类参考对象 ──
- # 参考数据 (非某台某窗的判断), 所以不受 C5 窗规则约束; 但必须双语成对,
- # 否则英文侧渲染出 [translation pending], 而这是要给客户看的骨架。
- 'MaintSystem', # 系统分解 (三位作业能力码: 3xx 通用 / 4xx 机型专属)
- 'DetectionMethod', # 检出方式 (C15; 10 个受控值, 含 unknown — 见 codes.detection_method)
- 'MaintStage', # 运维阶段 (6 个, 各带出口闸)
- 'RecordType', # 记录类型 (各带保存期与签署要求)
- }
- # 关系词表 (links 的 key; 图谱可游走性靠它 — 禁自造关系名, 新关系先进表)
- RELATIONS = {
- 'about', 'affects', 'basedOn', 'evidencedBy', 'resolves', 'partOf', 'contains',
- 'indicates', # 数据/表现/报警 → 失效模式 (征兆指向)
- 'explains', # Doc → 部件/失效模式 (原理与图纸解释)
- 'mitigatedBy', # 失效模式 → MaintTask (怎么防/怎么修)
- 'hasInstruction', # MaintTask → WorkInstruction (标准化作业)
- 'causedBy', # 失效模式 → 失效模式 (故障树: 子原因)
- 'appliesTo', # WI/Task → Component/Turbine (适用对象)
- 'scheduledIn', # MaintPlan → MaintenanceWindow
- 'plans', # MaintPlan → MaintTask
- # ── 2026-08-28 门禁 I3 对齐: 以下四种在图谱里实际使用但从未声明 ──
- # 未声明的关系 = 校验器看不见它, 写错方向或写错目标类型都不会被拦, 属静默风险。
- 'of', # 507+ 条: 从属/归属 (OilSample→Turbine, Evidence→Component 等)
- 'acceptedBy', # Decision → Acceptance (验收状态机)
- 'consumes', # Action → 备件/资源
- 'informs', # Evidence/Doc → Decision (支撑决策但非直接证据链)
- # ── 2026-09-01 C15 记录体系: 工单的三条归属边 ──
- # 根因: 失效模式/告警码/WI/体系骨架全都有 C 条款, **唯独 WorkOrder 零约束**,
- # 于是 109 个 FailureMode 与 2113 个 Part 只是躺在库里的孤岛 —— 没有任何
- # 规则要求工单去连它们。三条断链不是疏忽, 是约束表上缺了一行。
- 'detectedBy', # WorkOrder → DetectionMethod (怎么发现的)
- 'hasFailureMode', # WorkOrder → FailureMode (坏成什么样)
- 'atSystem', # WorkOrder → MaintSystem (位置码; 原为 prop.ge_system, 应成边)
- # 领料复用已有的 consumes (WorkOrder → Part), 不新造关系。
- # ── 2026-09-07 否定向边 (用户令; 夜思 2026-09-07 §二.2 逮出) ──
- # 根因: 此前 22 个关系**全是支持向**, 且 C2 强制 Verdict 必带 ≥1 evidencedBy —
- # 结构上无法表达"这条结论有反证"。活例: 08F 发电机 NDE−DE 阶跃四证俱全被
- # 同窗基率闸推翻 (|阶跃|≥2K 占 12/38), 那条反证只活在 SOP 散文与报告里,
- # 没落在图上 —— 查 verdict 的 links 仍只看得见当初的支持证据。
- # 谓词名取自 PROV-K (Giachelle et al., IRCDL 2025, ceur-ws.org/Vol-3937/paper10.pdf)
- # 的 supportedBy/conflictingWith, 非自造 (本表规定新关系优先借有文献出处的名字)。
- 'conflictingWith', # Verdict → Evidence (推翻/削弱该判断的证据; 与 evidencedBy 反向)
- }
- # ★C15 切换日: 此日期**之后**的工单必须带三条归属边; 之前的存量豁免。
- # 为什么不追溯: 2403 条存量一条都不满足, 硬卡会让整库过不了 validate; 且历史本来
- # 就无法补录 —— 补录的人不是当时在现场的人, 补出来的字段准确率不可信。
- WO_CUTOVER = '2026-10-01'
- # 声明但当前零使用 (保留而非删除: 排程链是设计中的能力, 删了会丢掉设计意图):
- # contains / plans / scheduledIn — 检修计划与作业窗口, 待排程链接入后启用。
- # 门禁对"声明未用"只报 WARN 不报 FAIL, 正是为这类"已设计未接线"留位。
- # 已声明但主库尚无边的关系 (I4 白名单)。★ 落第一条真边时必须从本表移除, 否则 I4 永远盲。
- # conflictingWith 已于 2026-09-07 落第一条真边 (verdict/thermal/WTG08/gen_nde_step
- # → evidence/thermal/WTG08/gen_nde_step_baserate), 按本行规矩当场移出白名单。
- UNUSED_OK = {'contains', 'plans', 'scheduledIn'}
- # 失效模式分类 (ISO 14224 风格; 用于故障树归并与统计)
- FM_CLASSES = CODES['fm_class'] # 码; 中文/英文显示查 codes.label('fm_class', c, lang)
- # 观测面枚举 (C3: 源数按物理维度去重, 不按证据条数 — "四个观测面一条机制链")
- FACETS = CODES['facet']
- # 判读三分 (C4: 禁把"不可判"写成阴性)
- CROSSCHECK = CODES['crosscheck']
- # 动作闸 (C6: 照现有可逆性三级; L3 必须带审级)
- REVERSIBILITY = CODES['reversibility']
- REVIEW_TIERS = CODES['review_tier']
- _PROGRESSIVE = re.compile(r'(恶化中|进行中|正在|持续上升|在升|上升中|持续升高|持续恶化)')
- _WINDOWED = re.compile(r'(截至|窗|\d{4}-\d{2})')
- class ConstraintError(ValueError):
- pass
- _WO_DATE = re.compile(r'^\s*(\d{4})[-./年](\d{1,2})[-./月](\d{1,2})')
- def _wo_date(v):
- """工单日期 → ISO 'YYYY-MM-DD'; 解析不出返回 None (响亮地按存量处理, 不猜)。
- 存量实测四种形态: ISO 短横 4276 / 其他 13 / 空 7 / 点分隔 2。
- **不可拿原串比字典序** —— '.' (0x2E) > '-' (0x2D), 于是 '2026.6.21' >= '2026-10-01'
- 成立, 旧记录会假冒成切换日之后而被闸误拦。首版就是这么错的。
- """
- if not v:
- return None
- m = _WO_DATE.match(str(v))
- return '%s-%02d-%02d' % (m.group(1), int(m.group(2)), int(m.group(3))) if m else None
- def _req(obj, path, msg):
- cur = obj
- for k in path.split('.'):
- if not isinstance(cur, dict) or k not in cur or cur[k] in (None, '', []):
- raise ConstraintError(f"[{obj.get('id','?')}] {msg} (缺 {path})")
- cur = cur[k]
- return cur
- def validate(obj, registry=None, strict=False):
- """写入闸: 不过即 raise (判据必需输入禁容错取值的本体版).
- registry: 全库 dict(id→obj); 传入时追加引用完整性/跨对象一致性校验 (save 全库时传).
- strict: True 时枚举只认 codes.py 的码 (新生成器用), False 兼容存量中文值 (读旧库用)."""
- _req(obj, 'id', '对象必须有 id')
- t = _req(obj, 'type', '对象必须有 type')
- if t not in OBJECT_TYPES:
- raise ConstraintError(f"[{obj['id']}] 未知对象类型 {t}")
- props = obj.get('props', {})
- # ---- 检修助手四链约束 (C7-C10) ----
- if t == 'FailureMode':
- _req(obj, 'props.name', 'C7: 失效模式必带 name')
- cls = _req(obj, 'props.fm_class', 'C7: 失效模式必带 fm_class (ISO14224 风格分类)')
- ok, _ = _enum_ok('fm_class', cls, strict)
- if not ok:
- raise ConstraintError(f"[{obj['id']}] C7: fm_class '{cls}' 不在码表 {sorted(FM_CLASSES)}")
- if not obj.get('links', {}).get('affects'):
- raise ConstraintError(f"[{obj['id']}] C7: 失效模式必须 affects 至少一个部件 — 不挂部件的模式无法进检修链")
- if t == 'AlarmCode':
- _req(obj, 'props.code', 'C8: 报警码必带 code')
- _req(obj, 'props.source', 'C8: 报警码必带 source (码表出处 — 码义靠猜会翻案)')
- if t == 'WorkInstruction':
- _req(obj, 'props.steps', 'C9: WI 必带 steps (无步骤的"指导书"不可执行)')
- _req(obj, 'props.source', 'C9: WI 必带 source (手册/作业指导书出处, 便于回溯原文)')
- if t == 'MaintTask':
- tk = _req(obj, 'props.task_kind', 'C10: 检修任务必带 task_kind (预防性/抢修/检查)')
- if not _enum_ok('task_kind', tk, strict)[0]:
- raise ConstraintError(f"[{obj['id']}] C10: task_kind '{tk}' 非法")
- if tk == '预防性' and not props.get('interval'):
- raise ConstraintError(f"[{obj['id']}] C10: 预防性任务必带 interval (周期; 无周期=不可排程)")
- if t == 'MaintPlan':
- _req(obj, 'props.rank_basis', 'C11: 排程计划必带 rank_basis (按什么排序 — 损失/风险须显式)')
- # ---- C12/C13: 运维管理体系约束 (每条都由如东实证倒逼, 非凭空设计) ----
- if t in ('MaintSystem', 'MaintStage', 'RecordType'):
- # C14: 体系骨架必须双语成对 —— 这三类是给客户看的框架, 缺一侧就会在
- # 英文界面上渲染成 [translation pending], 等于框架有洞。
- for k in ('name_zh', 'name_en'):
- _req(obj, f'props.{k}', f'C14: 体系对象必带 {k} (双语成对, 不许单写)')
- if t == 'MaintSystem' and props.get('tier') not in ('basic', 'eng', 'common', 'platform'):
- raise ConstraintError(f"[{obj['id']}] C14: MaintSystem 必带 tier "
- f"∈ basic/eng/common/platform (通用与机型专属的分界是这套体系的用处所在)")
- if t == 'MaintStage':
- _req(obj, 'props.gate_zh', 'C14: 阶段必带出口闸 — 无闸的阶段划分只是标签')
- _req(obj, 'props.gate_en', 'C14: 阶段必带出口闸 (英文侧)')
- if t == 'RecordType':
- _req(obj, 'props.retention', 'C14: 记录类型必带保存期')
- # ---- C15: 工单记录体系 (2026-09-01) ----
- if t == 'DetectionMethod':
- for k in ('name_zh', 'name_en'):
- _req(obj, f'props.{k}', f'C15: 检出方式必带 {k} (双语成对)')
- c = _req(obj, 'props.code', 'C15: 检出方式必带 code')
- if not _enum_ok('detection_method', c, strict)[0]:
- raise ConstraintError(f"[{obj['id']}] C15: detection_method '{c}' 不在码表")
- if t == 'WorkOrder':
- # ★口径一: type='WorkOrder' 覆盖四个 id 家族 (workorder 2403 / dailyreport 1811 /
- # faultrecord 64 / majorrepair 20)。**检修日报是班次记录不是失效记录**, 且场级日报
- # 按设计就没有单台机组 (实测 25 条 dailyreport/fleet/*), 故 C15 只管失效记录家族。
- if obj['id'].split('/')[0] in ('workorder', 'faultrecord', 'majorrepair'):
- d = _wo_date(props.get('date'))
- # ★口径二: 切换日之前的存量豁免 (理由见 WO_CUTOVER)。日期解析不出来的一律按存量 —
- # 新记录出自带日期选择器的新模板, 不可能没有日期或写成非 ISO 格式。
- # (首版直接拿原串比字典序, 被 '2026.6.21' 骗过去 —— '.' > '-', 旧记录假冒成
- # 切换日之后。日期比较前必须归一, 不能拿原串比。)
- if d and d >= WO_CUTOVER:
- _req(obj, 'props.turbine', 'C15: 切换日起失效记录必带 turbine')
- lk = obj.get('links') or {}
- for rel, why in (
- ('atSystem', '位置码必填 (75 个 RDS-PP 下拉)'),
- ('hasFailureMode', '失效模式必填 (109 个下拉)'),
- ('detectedBy', '检出方式必填 — 含"不确定", 但不许留空')):
- if not lk.get(rel):
- raise ConstraintError(
- f"[{obj['id']}] C15: {WO_CUTOVER} 起工单必带 links.{rel} — {why}")
- if t == 'Finding':
- cr = _req(obj, 'props.criterion', 'C12: 管理发现必带 criterion (R1-R4 哪条判据)')
- if cr not in ('R1', 'R2', 'R3', 'R4'):
- raise ConstraintError(f"[{obj['id']}] C12: criterion '{cr}' 非法 (应 ∈ R1/R2/R3/R4)")
- _req(obj, 'props.claim_window', 'C12: 管理发现必带 claim_window (台账窗外不背书)')
- # ★如东实证: 工单"条数"≠作业"次数" — 同日重复条目使计数虚高 1.18×, WTG31 STIC 6条=3次。
- # 凡以计数支撑的发现, 必须声明用的是哪个口径, 否则数字不可复现。
- if props.get('count') is not None and not props.get('count_basis'):
- raise ConstraintError(f"[{obj['id']}] C12: 带 count 必须声明 count_basis "
- f"(work_orders=工单条数 / work_sessions=作业次数; 如东实测两者差 1.18×)")
- g = props.get('grade')
- if g is not None and not _enum_ok('grade', g, strict)[0]:
- raise ConstraintError(f"[{obj['id']}] C12: grade '{g}' 不在码表 {sorted(CODES['grade'])}")
- # ★如东实证: 螺栓复发因无"螺栓位置"字段而无法区分同位复发/异位各一次;
- # IGBT 批次与记录制度变更同年 — 两条都必须随发现一起走, 否则下游读成硬结论。
- if props.get('caveat') in ('', [], {}):
- raise ConstraintError(f"[{obj['id']}] C12: caveat 存在但为空 — 无保留写 null, 别留空壳")
- # C12b: 光有 count_basis 不够 —— 取值必须在码表里。
- # 只查"有没有"会让字段被填成手边最近的词 (2026-08-31 实测 3/5 条错标),
- # 那反而更危险: 下游看到字段有值, 以为口径已声明。
- if props.get('count_basis') and not _enum_ok('count_basis', props['count_basis'], True):
- raise ConstraintError(
- f"[{obj['id']}] C12b: count_basis='{props['count_basis']}' 不在码表 —— "
- f"count={props.get('count')} 数的到底是什么? 从 codes.LABELS['count_basis'] 里选")
- if t == 'Assessment':
- dims = _req(obj, 'props.dimensions', 'C13: 评估必带 dimensions (逐维结果)')
- if not isinstance(dims, dict):
- raise ConstraintError(f"[{obj['id']}] C13: dimensions 必须是 dict(维度→结果)")
- # ★如东实证: 首版把"字段不存在"当成"工作没做"记 0 分, 得 2.6/100 —— 那是判据错不是现场差。
- # 每个维度必须显式声明可评估性; 不可评估的维度不进分母。
- for k, v in dims.items():
- if not isinstance(v, dict) or 'evaluable' not in v:
- raise ConstraintError(f"[{obj['id']}] C13: 维度 '{k}' 必带 evaluable "
- f"(数据字段不存在 ⇒ 不可评估, 不是 0 分)")
- if v['evaluable'] and v.get('score') is None:
- raise ConstraintError(f"[{obj['id']}] C13: 维度 '{k}' 声明可评估却无 score")
- _req(obj, 'props.evaluable_dims', 'C13: 必带 evaluable_dims (分母 — 总分只在可评估维上归一)')
- if t == 'Evidence':
- _req(obj, 'props.claim_window', 'C1: Evidence 必带 claim_window (证据窗外该证据不背书)')
- _req(obj, 'props.source', 'C1: Evidence 必带 source (数据源身份/产物路径)')
- facet = _req(obj, 'props.facet', 'C1: Evidence 必带 facet (观测面)')
- ok, _ = _enum_ok('facet', facet, strict)
- if not ok:
- raise ConstraintError(f"[{obj['id']}] C1: facet '{facet}' 不在码表 {sorted(FACETS)}")
- cc = props.get('crosscheck')
- if cc is not None and not _enum_ok('crosscheck', cc, strict)[0]:
- raise ConstraintError(f"[{obj['id']}] C4: crosscheck '{cc}' 不在三分枚举 {sorted(CROSSCHECK)} "
- f"(禁把'不可判'写成阴性/排除)")
- if t == 'Decision':
- # ---- C16 (2026-09-07; 审逮 "schema 对 Decision 零约束"): 最小约束, 不破坏存量 33 条 (全有 name; status/kind/closure_type 存量为空则不查) ----
- _req(obj, 'props.name', 'C16: Decision 必带 name (裁了什么)')
- for fld, key in (('closure_type', 'closure_type'), ('status', 'decision_status'), ('kind', 'decision_kind')):
- v = props.get(fld)
- if v is not None and not _enum_ok(key, v, strict)[0]:
- raise ConstraintError(f"[{obj['id']}] C16: Decision.{fld} '{v}' 不在码表 {sorted(CODES.get(key, ()))}")
- if props.get('kind') in ('closure_proposal', 'closure_migrated'):
- if not props.get('closure_type'): raise ConstraintError(f"[{obj['id']}] C16: 归宿类 Decision 必带 closure_type")
- if not obj.get('links', {}).get('basedOn'): raise ConstraintError(f"[{obj['id']}] C16: 归宿类 Decision 必 basedOn 指向 Verdict")
- if t == 'Verdict':
- _req(obj, 'props.review_tier', 'C2: Verdict 必带审级')
- if not _enum_ok('review_tier', props['review_tier'], strict)[0]:
- raise ConstraintError(f"[{obj['id']}] C2: 审级 '{props['review_tier']}' 不在 {sorted(REVIEW_TIERS)}")
- # ── C2 v2 (2026-09-07 用户令 "改 C2, 允许撤回态 Verdict 只挂反证") ──
- # 原 C2 只认支持向, 于是被推翻的结论只有两条路: 保留一条支持证据 (等于仍在主张)
- # 或整条删掉 (等于历史消失) —— "已撤回但保留证据链"结构上不存在。
- # 改法不是**放松**证据要求, 是**换极性**: 撤回态照样必须有据, 只是那据是反证。
- # 故 C2 无逃逸口 —— 任何 Verdict 都必须至少挂一条某极性的 Evidence。
- # grade 用既有六枚举码表 (codes.CODES['grade'], = SOP 六枚举), 不新造枚举;
- # 不复用 props.level: level 是 L1 判级 (优秀/良好/报警…), 且被 audit 第5项拿去与
- # 实时 L1 对拍, 借位会把两个语义压进一个字段。
- grade = props.get('grade')
- if grade is None:
- gcode = None # 存量 371 条无 grade → 走原路径, 不破坏
- else:
- ok, gcode = _enum_ok('grade', grade, strict)
- if not ok:
- raise ConstraintError(f"[{obj['id']}] C2: Verdict.grade '{grade}' 不在六枚举 "
- f"{sorted(CODES.get('grade', ()))}")
- links = obj.get('links', {})
- ev = links.get('evidencedBy', [])
- cw = links.get('conflictingWith', [])
- if gcode == 'withdrawn':
- # 撤回必须有据: 无反证的"撤回"= 静默删除, 事后无从复核为什么撤。
- if not cw:
- raise ConstraintError(
- f"[{obj['id']}] C2: 撤回态 Verdict 必须链接 >=1 条 conflictingWith Evidence "
- f"(撤回须有据 — 无据的撤回 = 静默删除, 事后无从复核)")
- elif not ev:
- raise ConstraintError(f"[{obj['id']}] C2: Verdict 必须链接 ≥1 条 Evidence")
- _check_evidence_refs(obj, ev, registry)
- _check_evidence_refs(obj, cw, registry, rel='conflictingWith')
- if t == 'FailureMechanism':
- # C3 硬校验: 独立源数 = 观测面去重数 (禁按 Evidence 条数计源)
- facets = _req(obj, 'props.facets', 'C3: 机制必带 facets 观测面列表')
- bad = [f for f in facets if not _enum_ok('facet', f, strict)[0]]
- if bad:
- raise ConstraintError(f"[{obj['id']}] C3: facets 含未知观测面 {bad} (枚举 {sorted(FACETS)})")
- if len(facets) != len(set(facets)):
- raise ConstraintError(f"[{obj['id']}] C3: facets 含重复面 {facets} — 同面多证据≠多面")
- isc = props.get('independent_source_count')
- if not isinstance(isc, int):
- raise ConstraintError(f"[{obj['id']}] C3: 机制必带 independent_source_count (int, =facet 去重数; "
- f"禁按 Evidence 条数计源)")
- if isc != len(set(facets)):
- raise ConstraintError(f"[{obj['id']}] C3: independent_source_count={isc} ≠ facet 去重数 "
- f"{len(set(facets))} ({sorted(set(facets))})")
- ev = obj.get('links', {}).get('evidencedBy', [])
- _check_evidence_refs(obj, ev, registry)
- if registry is not None and ev:
- ev_facets = {_norm('facet', registry[x]['props']['facet']) for x in ev if x in registry}
- if ev_facets != {_norm('facet', f) for f in facets}: # 码空间比较, 免中文/码混存时假不等
- raise ConstraintError(f"[{obj['id']}] C3: 声明 facets {sorted(set(facets))} ≠ "
- f"证据实际观测面 {sorted(ev_facets)} — 声明面必须由证据支撑")
- if t == 'Action':
- _req(obj, 'props.gate', 'C6: Action 必带 gate')
- g = props['gate']
- if not _enum_ok('reversibility', g.get('reversibility'), strict)[0]:
- raise ConstraintError(f"[{obj['id']}] C6: gate.reversibility 必须 ∈ {sorted(REVERSIBILITY)}")
- if _norm('reversibility', g['reversibility']) == 'L3_approval' \
- and not _enum_ok('review_tier', g.get('review_tier'), strict)[0]:
- raise ConstraintError(f"[{obj['id']}] C6: L3 动作必须带审级 (建议无界, 动作受闸)")
- # C5: 状态措辞窗规则 — 进行时态措辞必须带窗:
- # 对象 props.claim_window 非空 = 整对象已声明窗 (约束本义); 否则逐字符串扫内嵌窗标注.
- # ★适用域 = 产判级/下结论的类型。字典类 (AlarmCode/Doc/WorkInstruction/MaintTask) 是**第三方词条转录**,
- # "INU错误:发电机正在励磁" 是码表原文的机制定性, 不是我方对某台某窗的状态断言 —
- # 对它套 C5 等于要求给厂商字典加窗, 属闸的适用域错配 (2026-08-28 kb_ingest 实逮)。
- _C5_SCOPE = {'Evidence', 'Verdict', 'Decision', 'FailureMechanism', 'FailureMode', 'Action', 'MaintPlan'}
- if t in _C5_SCOPE and not props.get('claim_window'):
- def _scan(v, key):
- if isinstance(v, str) and _PROGRESSIVE.search(v) and not _WINDOWED.search(v):
- raise ConstraintError(f"[{obj['id']}] C5: '{key}' 含进行时态措辞而无窗标注 "
- f"(机制定性窗无关可长期挂; 状态判断窗相关必带窗): {v[:60]}")
- if isinstance(v, dict):
- for k2, v2 in v.items(): _scan(v2, f'{key}.{k2}')
- if isinstance(v, list):
- for i, v2 in enumerate(v): _scan(v2, f'{key}[{i}]')
- _scan(props, 'props')
- return obj
- def _check_evidence_refs(obj, ev_ids, registry, rel='evidencedBy'):
- """C2 引用完整性: evidencedBy / conflictingWith 必须指向真实存在的 Evidence 对象.
- rel 只影响报错文案; 两个极性同等严格 —— 反证挂悬空 id 或非 Evidence 对象与支持证据
- 挂错一样是硬错, 否则"撤回须有据"可以用一条假 id 糊弄过去。"""
- if registry is None:
- return
- for x in ev_ids:
- if x not in registry:
- raise ConstraintError(f"[{obj['id']}] C2: {rel} 指向不存在的对象 {x} (悬空引用)")
- if registry[x]['type'] != 'Evidence':
- raise ConstraintError(f"[{obj['id']}] C2: {rel} 指向非 Evidence 对象 {x} "
- f"(type={registry[x]['type']})")
- def facet_count(evidence_objs):
- """C3: 机制的独立源数 = 观测面去重数, 禁按 Evidence 条数 (同一失效机制的多观测面≠多源印证)."""
- return len({e['props']['facet'] for e in evidence_objs})
|