# -*- coding: utf-8 -*-
"""md_cg · 节点间自动冲突检测（三级决策：情绪 → 反思 → 递归反思）

理论出处（本仓原文，非外部知识）：

  · **情绪 = 信息差的二阶变化 d²D/dt²**（`docs/theory/智能的公理化基石.md` §十一，:412-510）
    工程端对应 `emotional_bias`（approaching / avoiding / stable），并且原文明确：
    「情绪通道**独立、不参与信任计算**」。故本模块 L0 只做**流程调度**，
    绝不改动 confidence / 资格判定——避免把情绪混进事实判断。

  · **反题 = 预测与事实冲突**（同文档 :529，条件论七操作之一）
    —— L1 一次冲突检测的理论名：新信息与既有条件的「反题」关系。

  · **递归必须受约束**（同文档 :273）：
    「递归必须受到深度、查询次数、节点数、循环检测和信息增益门槛约束。
     若递归没有减少候选空间，就不应继续搜索。」
    —— L2 递归反思的硬约束（本模块按此实现，而非无限展开）。

  · **四态路由** ACCEPT / REJECT / DEFER / BLINDSPOT（同文档 :721；
    `md_cg/mdcg.py` 四态定义）—— L1 的输出语义。

  · **知识飞轮：误差 → 补条件 → 结构更新**（同文档 :725；`mdcg.py:1001`
    `flywheel_step`）—— 冲突**自动**落 unresolved，误差是结构更新的输入。

  · **纪律四要素同构**（`docs/工作纪律_认知图条目_v1.1.json`；
    `consolidate.py` CCG 四要素）—— 「不能违反纪律」= 新内容不得命中纪律节点的
    `negative.reject`（不适用条件）。纪律节点用 tag/前缀识别，不硬编码具体条目。

三级决策（顺序即语义，越靠前越廉价）：

    L0 情绪    ：二阶信号，快速方向调整（approaching / stable / avoiding）——不裁决资格
    L1 反思    ：一次条件级冲突检测（自否定 / 纪律违反 / 条件互斥）→ 四态
    L2 递归反思：L1 未决 → 沿关系链递归找「区分条件」；受深度/节点数/循环/增益门槛约束

冲突自动触发飞轮：verdict == REJECT（**真冲突**）且 auto_flywheel →
    `cg.flywheel_step({query, expected_state:"ACCEPT", actual_state:verdict, missing})`
    （H11⑥ 2026-09-30：DEFER/BLINDSPOT 不再自动建单——DEFER 是「待确认」、
    BLINDSPOT 是「检测前提不存在（无可比对节点，空库上恒成立）」，二者都不是
    已判定的冲突；把待确认写成待办工单只是把误判固化。判定本身仍如实返回。）

留痕 `_consistency.jsonl`（append-only）：每条判定可审计「为什么冲突 / 为什么放行」。

诚实边界：本模块是**条件级（结构化）**冲突检测，不是语义蕴含证明。它判断的是
「声明的适用/不适用条件是否互相覆盖」，而非「两句话在逻辑上是否矛盾」。
无法建立可比对路径时返回 BLINDSPOT，不假装确定。
"""
from __future__ import annotations

import json
import os
import time

from .mdcg import expand_query_terms_weighted
from . import nodefile
from .fsutil import ends_mid_line

# --------------------------------------------------------------------------
# 常量（全部可审计、可调）
# --------------------------------------------------------------------------

LOG_FILE = "_consistency.jsonl"

# ---- issue #298（2026-10-14 DSH 端）：台账的密级面 ----
#
# 病灶：`_consistency.jsonl` 是**明文** append-only 台账，而 `check()` 完全不知道
# 写入的密级。一条声明 `sensitivity=private` 的写入，节点本体加密落盘，同一次判定
# 的 `pos`/`neg`（生效条件/不适用条件原文）、`conflicts[].pos_side`/`neg_side`、
# `slot`（功能名前 40 字）、`cond_routes.only_new_side`/`only_existing_side`
# 却照样明文记下；`consistency.history` 只要求 write 权限、不过可见性过滤，
# 于是 clearance 仅 internal 的 record/sustain/verify/reflect/orchestr 令牌
# 能原样读到同一批节点 `get` 判 `not_found` 的条件原文。
#
# 修法：**落盘副本**去原文（`check()` 返回给调用方的回执一字不动）。密级取
# 「调用方声明值」与「本次比对的既有节点各自密级」的最高档——`only_existing_side`
# 记的正是被比对节点的条件，故既有节点侧必须一起进密级面。
#
# 序与 `security.SENSITIVITY_ORDER` 逐位同序（此处复制而非 import：本模块被
# `mdcos`/`security` 下游大量引用，反向 import 会成环）。
_LEDGER_LEVELS = ("public", "internal", "restricted", "private", "secret")

# 台账里承载「条件原文 / 功能名槽」的字段——高于 internal 时一律去原文。
_LEDGER_TEXT_FIELDS = ("pos", "neg")
_LEDGER_CONFLICT_FIELDS = ("pos_side", "neg_side", "slot")
_LEDGER_ROUTE_FIELDS = ("only_new_side", "only_existing_side")


# 生效条件：形参 level 为 _LEDGER_LEVELS 中的元素时返回其下标；假值返回 None（视为未声明）；非法值返回 len(_LEDGER_LEVELS)（=最高档，fail-closed）。
def _level_rank(level):
    """密级序（与 security.SENSITIVITY_ORDER 同序）；假值 None、非法值 fail-closed 最高档。"""
    s = str(level or "").strip().lower()
    if not s:
        return None
    return (_LEDGER_LEVELS.index(s) if s in _LEDGER_LEVELS
            else len(_LEDGER_LEVELS))


# 生效条件：declared 为调用方声明密级、levels 为本次比对到的既有节点密级序列；取两者最高档，全为假值时回落 internal。
def ledger_level(declared, levels=None):
    """本次判定的**落账密级**＝声明值与各被比对既有节点密级中的最高档。

    未声明（None/空串）按 internal（与 #87 缺省档一致，零变化）；
    非法值 fail-closed 按最高档 secret（宁可多抹，不可越权）。
    """
    best = "internal"
    best_r = _LEDGER_LEVELS.index(best)
    for x in [declared] + list(levels or []):
        r = _level_rank(x)
        if r is None:
            continue
        if r > best_r:
            best_r, best = r, _LEDGER_LEVELS[min(r, len(_LEDGER_LEVELS) - 1)]
    return best


# 生效条件：rec 为判定留痕字典时返回**去原文副本**（原 rec 不被修改）；rec 非字典时原样返回。
def scrub_record(rec):
    """去原文副本：条件原文与功能名槽置空，判据数值 / verdict / 节点 id 照常保留。

    只动副本——`check()` 返回给调用方的回执不受影响（复核面需要原文，
    落盘面不需要）。节点 id 保留是刻意的：台账的可审计性（哪两个节点冲突）
    不依赖条件原文，而 id 本身已受 `get`/`search` 的密级过滤保护。
    """
    if not isinstance(rec, dict):
        return rec
    out = dict(rec)
    for k in _LEDGER_TEXT_FIELDS:
        if k in out:
            out[k] = []
    if isinstance(out.get("conflicts"), list):
        cs = []
        for c in out["conflicts"]:
            if not isinstance(c, dict):
                cs.append(c)
                continue
            c2 = dict(c)
            for k in _LEDGER_CONFLICT_FIELDS:
                if k in c2:
                    c2[k] = [] if isinstance(c2[k], list) else ""
            cr = c2.get("cond_routes")
            if isinstance(cr, dict):
                cr = dict(cr)
                for k in _LEDGER_ROUTE_FIELDS:
                    if k in cr:
                        cr[k] = []
                c2["cond_routes"] = cr
            cs.append(c2)
        out["conflicts"] = cs
    return out


MAX_SCAN = 200       # 单次检测最多比对的既有节点数（防 O(N) 爆炸）
                     # cons200（2026-10-03）：本值是**相关面/保底面各自的限额**，不是
                     # 「索引序前 N」——数值一字未动（契约禁止面）；超限走可观测
                     # （truncated/kept/hint），修法是选面与预筛，不是调大本值。
MAX_DEPTH = 3        # L2 递归深度上限（对齐 :273）
MAX_NODES = 60       # L2 递归展开节点上限（对齐 :273）
MIN_GAIN = 0.15      # 信息增益门槛：候选空间减少比例低于此值即停（对齐 :273）

# 截断可操作提示（cons200：与 stg `_CAP_HINT` 同口径精神——禁止静默）——文案必须
# 含「细化生效条件/不适用条件」与「建立条件索引」语义；数值放大（调 limit/MAX_SCAN）
# 不是修法，故明文劝阻。
_SCAN_HINT = ("候选 %s 条超过单次检测限额 limit=%s，已按相关性保留 %s 条"
              "（截断不静默）：请细化生效条件/不适用条件收窄候选集，"
              "或按设计稿建立条件索引（docs/plans/stg条件化与结构索引_设计_v0.1.md）"
              "——不要调大 limit/MAX_SCAN 数值。")

CLASH_HIGH = 0.6     # 条件互相覆盖阈值 → 明确互斥
CLASH_LOW = 0.35     # 条件部分覆盖阈值 → 待定
SELF_NEGATION = 0.5  # 自否定阈值：负条件被自身正文强命中

# —— 第二类关系（同条件空间 · 结论槽取值分歧）阈值；均由探针实测标定 ——
# run_probe_samecond.py 实测：同条件 same_cond=1.0 / 异条件空间 0.328 → 0.75 可分
SAME_COND_HIGH = 0.75   # 同侧条件重合 → 判定「同一条件空间」
# 实测结论槽（子功能）：同槽 1.0 / 同主语异属性 0.14 / 异主题 0.0 → 0.6 可分
SLOT_HIGH = 0.6         # 结论槽（功能名/子功能）重合 → 判定「同一件事」
# 实测：逐字重复 1.0 / 同槽不同值 0.61 / 同值异措辞 0.74 → 0.95 只排除逐字重复
# H8（2026-09-30）：两侧先过同一套空白归一化（`_norm_ws`）再比；且去空白后逐字
# 相同者直接短路为「重复」。阈值本身**不动**——纯空白差异（探针实测未归一化时
# 0.9145~0.9436）曾把「同条件·同槽」压到 0.95 以下造成假冲突，修法是消除口径
# 不对称，不是放宽 0.95（放宽到 0.9436 以下才能救，会一并吞掉真分歧）。
CONCLUSION_SAME = 0.95  # 正文几乎逐字相同 → 属「重复」（该合并），不算冲突

EMO_AVOID = 0.70     # 冲突强度 ≥ 此值 → avoiding
EMO_APPROACH = 0.30  # 冲突强度 ≤ 此值 → approaching

# 纪律节点识别（tag 或 id 前缀；不硬编码具体纪律条目）
DISCIPLINE_TAGS = ("discipline", "纪律", "work_discipline", "rule", "规则", "戒律")

# #412：**词形归一**与**命名空间**——生产者与判据必须同指（唯一实现，勿在他处再写一份 replace）。
DISCIPLINE_TAG_NS = "discipline"                 # 命名空间前缀（生产者的 `discipline:N`）
DISCIPLINE_ID_PREFIXES = ("discipline_", "work_discipline")   # 归一后比较（含 work-discipline 写法）


# 生效条件：对任意值返回 str(t or "") 去首尾空白、小写、并把 "-" 与空格折成 "_" 后的字符串；恒不抛。
def _norm_discipline_token(t) -> str:
    """纪律识别用的**词形归一单点**。

    #412 根因（2026-10-16，本函数即为此而设）：真实生产者
    `scripts/discipline_nodes.py:434` 写的 tag 是 `work-discipline`（**连字符**），
    而 `DISCIPLINE_TAGS` 里是 `work_discipline`（下划线）⇒ **精确求交恒为空** ⇒
    纪律闸对真实生产者**恒不命中**（完全空转，守卫因测试自造 id 而长期绿）。
    同一生产者的第二个字面量 `discipline:N` 是**命名空间**形态，精确相等同样不命中。
    归一让「同义异写」在**一处**收敛，而不是各判据各写一份 replace。
    """
    return str(t or "").strip().lower().replace("-", "_").replace(" ", "_")


_DISCIPLINE_TAGS_NORM = frozenset(_norm_discipline_token(t) for t in DISCIPLINE_TAGS)


# 生效条件：fm 与 node_id 给出时——tags 归一后命中「∈ DISCIPLINE_TAGS 归一集」或「以命名空间 `discipline:` 起」，或 node_id 归一后以 DISCIPLINE_ID_PREFIXES 起，返回 True；否则返回 False；fm 非映射/缺键/假值按空处理，恒不抛。
def is_discipline_node(fm, node_id=None) -> bool:
    """**纪律节点识别的唯一单点**（#412）：生产者与判据同用本谓词。

    命中（任一）：① tags 归一后 ∈ DISCIPLINE_TAGS 归一集（`work-discipline` 与
    `work_discipline` 因此同指）；② tags 归一后以命名空间 `discipline:` 起（生产者的
    `discipline:N`）；③ node_id 归一后以 `discipline_` / `work_discipline` 起。

    **明确不纳入**（同词不同义——本仓 `discipline` 是多义词，吞掉会造成误伤）：
      · `discipline_type` / `track`（`crosscheck.classify_track`）＝**学科/赛道**分类；
      · `meta_discipline`（`whitebox_kb/wisdom/migrate_wisdom.py` 的**元学科聚合卡**）＝学科面。
    二者由守卫的反例组钉住（防「过度收紧」：把学科卡当纪律 ⇒ 「学科卡被引用」会走硬冲突）。
    """
    try:
        tags = (fm or {}).get("tags") or []
    except AttributeError:                       # fm 非映射 ⇒ 按无 tags 处理，不抛
        tags = []
    for t in tags:
        n = _norm_discipline_token(t)
        if not n:
            continue
        if n in _DISCIPLINE_TAGS_NORM or n.startswith(DISCIPLINE_TAG_NS + ":"):
            return True
    return _norm_discipline_token(node_id).startswith(DISCIPLINE_ID_PREFIXES)

VERDICTS = ("ACCEPT", "REJECT", "DEFER", "BLINDSPOT")

#: 自动建单（飞轮）**唯一**的触发态——只有真冲突才落 unresolved（H11⑥，2026-09-30）。
#: 改前是 ("REJECT","DEFER","BLINDSPOT")：DEFER=「条件互斥/部分覆盖、待确认」、
#: BLINDSPOT=「无节点可比对（检测前提不存在）」——都不是「已判定的冲突」。
#: 单点：`check()` 与 `catalog()` 都读本元组。
FLYWHEEL_TRIGGERS = ("REJECT",)


# 生效条件：以 verdict 与 reason 构造异常（消息 `[{verdict}] {reason}`），conflicts 传假值（None/空容器等，源码 `conflicts or []`）时 self.conflicts 为 []，传真值时原样保留；
class ConsistencyError(Exception):
    """硬冲突：写入被拒（自否定 / 违反纪律）。"""

# 生效条件：verdict 与 reason 必传并赋给同名属性、拼成异常消息 `[{verdict}] {reason}`；conflicts 为假值（None/空容器等，源码 `conflicts or []`）时 self.conflicts 回落到 []，为真值时原样保留；
    def __init__(self, verdict, reason, conflicts=None):
        self.verdict = verdict
        self.reason = reason
        self.conflicts = conflicts or []
        super().__init__(f"[{verdict}] {reason}")


# --------------------------------------------------------------------------
# 原语：延迟导入 mdcos（避免 mdcg ← mdcos ← consistency 的循环导入）
# --------------------------------------------------------------------------

# 生效条件：无 required 形参，任一调用返回 ( _ccg_field, _declared_conditions, _neg_hit, _weighted_coverage ) 四元组；
def _prims():
    """取 md_cg 已有的条件匹配原语（条件论「反题」的既有实现）。"""
    from .mdcos import (_ccg_field, _declared_conditions, _neg_hit,
                        _weighted_coverage)
    return _ccg_field, _declared_conditions, _neg_hit, _weighted_coverage


# 生效条件：xs 为可迭代对象时，逐项 str(x).strip() 后仅当结果非空且未出现过才加入 out 并返回 out；xs 为 None/False/0 时在 for 处迭代失败（TypeError）；
def _dedup(xs):
    out = []
    for x in xs:
        s = str(x).strip()
        if s and s not in out:
            out.append(s)
    return out


# 生效条件：fm 给出时，遍历 fm.get("edges")（缺键或假值按空列表）中元素，dict 元素按 to→target→node→id 取首个真值、非 dict 元素直接作候选，候选真值时 str(t) 加入 out 并返回；
def _edge_targets(fm):
    """节点声明的出边目标（兼容 dict / str 两种形态）。"""
    out = []
    for e in (fm.get("edges") or []):
        if isinstance(e, dict):
            t = e.get("to") or e.get("target") or e.get("node") or e.get("id")
        else:
            t = e
        if t:
            out.append(str(t))
    return out


# 生效条件：同 `is_discipline_node(fm, node_id)`——本函数是它的**薄别名**（#412：判据单点化后保留既有名，
# 4 个调用点与公开面一字不动；逻辑不在此处第二份）。
def _is_discipline(fm, node_id):
    return is_discipline_node(fm, node_id)


# --------------------------------------------------------------------------
# 模式分离的支持件（maintain.separate 复用；也供 insight.reconstruct 取线索）
# --------------------------------------------------------------------------

#: 分离边的关系名：两个节点「看起来像，但条件不同，互为不同情境」
SEPARATION_REL = "distinct_from"


# 生效条件：fm 给出时，仅处理 fm.get("edges")（缺键/假值按空）中的 dict 元素，relation_type 或 relation（缺省空串）小写等于 SEPARATION_REL 时，按 target→to→id 取首个真值且未重复则加入 out；
def separation_targets(fm):
    """节点已声明的分离对象（`distinct_from` 出边），用于幂等与去重。"""
    out = []
    for e in (fm.get("edges") or []):
        if not isinstance(e, dict):
            continue
        rel = str(e.get("relation_type") or e.get("relation") or "").strip().lower()
        if rel != SEPARATION_REL:
            continue
        t = e.get("target") or e.get("to") or e.get("id")
        if t and str(t) not in out:
            out.append(str(t))
    return out


# 生效条件：fm 与 content 给出时，以 content（假值按空串）、fm.get("condition_space")、fm.get("non_applicable_conditions") 解析，返回去重后的 (set(pos), set(neg))；
def condition_terms(fm, content):
    """节点声明的（正条件, 负条件）词面集合——分离判定与重构取线索共用。"""
    pos, neg = _new_terms(content or "", fm.get("condition_space"),
                          fm.get("non_applicable_conditions"))
    return set(pos), set(neg)


# 生效条件：pos_a、neg_a、pos_b、neg_b 给出的条件词面小写化后，两侧并集均空时返回 overlap 0.0、distinct False；否则计算交集/并集比并返回 distinct 为 overlap <= CLASH_LOW；
def condition_distinct(pos_a, neg_a, pos_b, neg_b):
    """两组条件是否「实质不同」：任一侧条件词面几乎不重合即为不同情境。

    返回 {'overlap': 覆盖率, 'distinct': bool}。overlap 高 = 条件相同（是重复，
    该合并）；overlap 低 = 条件不同（该分离，避免混为一体）。
    """
    a = {str(x).lower() for x in (set(pos_a) | set(neg_a))}
    b = {str(x).lower() for x in (set(pos_b) | set(neg_b))}
    if not a and not b:
        return {"overlap": 0.0, "distinct": False,
                "note": "双方均未声明条件：无从判定分离（不假装确定）"}
    union = a | b
    overlap = (len(a & b) / float(len(union))) if union else 0.0
    return {"overlap": round(overlap, 4), "distinct": overlap <= CLASH_LOW}


# 生效条件：content、condition_space、non_applicable_conditions 给出时，content 的 `生效条件` 字段与 condition_space 中键不以 `__` 开头且不为 `time_window` 的值并入 pos（list/tuple/set 展平，其他非 None/"" 值 str 化），non_applicable_conditions 各项及 content 的 `不适用条件` 字段值并入 neg，最后 _dedup 返回；
def _new_terms(content, condition_space, non_applicable_conditions):
    """新节点声明的（正条件, 负条件）——与既有节点同口径解析。"""
    _ccg_field, _declared, _neg_hit, _cov = _prims()
    pos, neg = [], []
    v = _ccg_field(content or "", "生效条件")
    if v:
        pos.append(v)
    for k, val in (condition_space or {}).items():
        if str(k).startswith("__") or k == "time_window":
            continue
        if isinstance(val, (list, tuple, set)):
            pos.extend(str(x) for x in val)
        elif val not in (None, ""):
            pos.append(str(val))
    neg.extend(str(x) for x in (non_applicable_conditions or []))
    v2 = _ccg_field(content or "", "不适用条件")
    if v2:
        neg.append(v2)
    return _dedup(pos), _dedup(neg)


# 生效条件：content 与 neg_texts 给出时，neg_texts 中任一短语去掉「不得/禁止/严禁/不能/不可/不要/勿」前缀且长度 >=2 后作为子串出现在 content（假值按空串）中则返回 True，否则 False；
def _ban_hit(content, neg_texts):
    """纪律禁令命中：去掉「不得/禁止/…」前缀后，短语是否**整体出现**在正文中。

    比词袋匹配更严格——纪律条目通常是精确的禁令短语，用子串命中可避免
    「生产」这类子词把无关内容误判为违纪（假阳性会毁掉纪律的可信度）。
    """
    for x in (neg_texts or []):
        s = str(x).strip()
        for p in ("不得", "禁止", "严禁", "不能", "不可", "不要", "勿"):
            if s.startswith(p):
                s = s[len(p):].strip()
                break
        if len(s) >= 2 and s in (content or ""):
            return True
    return False


# 结论面字段（issue #88，2026-10-09 DSH 端）——**单点常量，一行可换口径**。
# 裁定（zcode 2026-10-09）：默认取「子功能 + 执行」。子功能＝主张面、执行＝做法面，
# 两者任一不同都算真实分歧；「功能名」是标签（易造成片面相似）、「生效条件」已由条件
# 空间匹配单独处理、「不适用条件」的自否定面已证明必须排除（见 _body_text 说明）。
# 「验证方式」暂不纳入：它是**证据面**不是结论面，纳进去会让「同一结论、不同取证」
# 被误报分歧（而分歧的代价是进 DEFER 队列人工裁）。
CONCLUSION_FIELDS = ("执行",)
# 是否把**自由正文**（_body_text 剥净六要素后的余文）并入结论面。
# 实测依据（2026-10-09 DSH 端）：p11 的 G2/G3 标定用例**没有「# 执行」行**，
# 结论写在自由正文里；若只取字段，这两条会因结论面皆空/皆同而判「重复」，
# 真分歧被吞（实测 ACCEPT/None/0，43 通过 2 失败）。并入正文后与改前口径
# 在「无执行行」的形态上完全一致，只在「有执行行」时**补回被剥掉的结论**。
CONCLUSION_INCLUDE_BODY = True


# 生效条件：恒返回 content 中 CONCLUSION_FIELDS 各字段值按序以换行连接后的串；字段缺失或为空则跳过，全空返回空串。
def _conclusion_text(content):
    """结论面：只取承载「结论/主张」的 CCG 字段。

    与 `_body_text` **分离**（issue #88）：`_body_text` 把六要素**整段**剥掉，
    其中包含承载结论的「# 执行」行；而 policy 强制 text 类须写成六要素 ⇒ **最规范的记忆
    正文恒为空**，结论比较两侧皆空、恒判相等 ⇒ 同条件下 5432 与 6543 判「无冲突」。

    为什么**不就地扩** `_body_text`（路线 B 不成立，纯实证）：它同时被
    **自否定检测**消费（check 内 `_ban_hit(body, neg)` / `_cov(tw_neg, body)`），
    那里**必须**剥掉「不适用条件」——否则每个声明了不适用条件的正常节点都会被误判为
    自相矛盾（见其 docstring）。故本函数独立存在，`_body_text` 的行为**一字不动**。
    """
    parts = []
    for f in CONCLUSION_FIELDS:
        v = nodefile.ccg_field_value(content, f)
        if v:
            parts.append(v)
    if CONCLUSION_INCLUDE_BODY:
        # 自由正文：_body_text 剥净六要素后的余文（**只读复用**，不改其行为）
        _b = _body_text(content)
        if _b and _b.strip():
            parts.append(_b.strip())
    return "\n".join(parts)


# 生效条件：委托 nodefile._strip_ccg_segments(content, nodefile.CCG_MARKS)（剥除算法单点）——命中 CCG 要素标题行（判据单点 nodefile._ccg_heading_rest，`^#\s*<要素>` 冒号可有可无）即整段剥除：行内带值（`# 生效条件：v`／前缀式）只剥该行，裸标题（`# 不适用条件`）连同其后首个非空、非标题行（该字段的值行，取值口径同 nodefile.ccg_field_value）一并剥除；其余原行以换行连接返回；content 假值按空串返回 "";
def _body_text(content):
    """去掉 CCG 声明**整段字段**后的正文。

    自否定看的是「正文/生效条件是否与不适用条件矛盾」，不能把节点自己声明的
    `# 不适用条件：X` 当成 X 出现在正文里——否则**每个**声明了不适用条件的
    正常节点都会被误判为自相矛盾（真实 CCG 条目普遍带该字段）。

    N238：判据复用**单点** `nodefile._ccg_heading_rest`（与写入闸门
    data/policy.json 同一行语义、`mdcos._ccg_field` / `ccg_mark_present` 同源），
    并连同**无冒号形态的值行**一起剥。此前本地判据是「以 # 开头**且含冒号**」，
    裸标题 `# 不适用条件` 与其值行双双残留正文：同内容写成带冒号时
    `_body_text == ''`（不命中），写成无冒号时 body 含禁令短语 ⇒ `_ban_hit`
    恒中 ⇒ self_negation / strength=1.0 ⇒ REJECT（writepipe 下合法节点被拦入
    review_queue 并自动建飞轮工单）。「冒号可有可无」已由
    `nodefile.ccg_mark_present` / `ccg_field_value` 定案（test_ccg_form_parity
    验收 PASS），本处是漏网的分叉。

    A3 追认：剥除算法本身也已收单点 `nodefile._strip_ccg_segments`（六要素全剥）
    ——本处不再自持循环，避免与 `nodefile.positive_body` 各写一份而再次漂移。
    """
    return nodefile._strip_ccg_segments(content, nodefile.CCG_MARKS)


# 生效条件：text 假值时按空串处理，返回把任意连续空白（半角/全角空格、\t、\r、\n）折叠为单个半角空格并去掉首尾空白后的字符串；纯空白文本返回空串 "";
def _norm_ws(text):
    """空白归一化（仓内既有惯用法 `" ".join(x.split())`，见 writelimit.py:233
    与 tool_face.py:136）——结论比对**两侧必须同一套**。

    为什么需要（H8）：结论文本是被 `expand_query_terms_weighted` 当作**一个整段
    加权词**（`mdcg.py` 整句 `put(q, 1.0)`）+ 若干分词来比对的，而 `_term_degree`
    对「非整段命中」只给子串回退分 `0.5·L/n`。两侧若只差空白（`\r\n`↔`\n`、尾部
    多换行、行首缩进（半角/全角）、插空行、行尾空格、词间多空格），整段词就只能
    拿到部分分，`conclusion_overlap` 掉到 0.95 以下 → 被误判成「同条件·同槽·取值
    不同」（假冲突 + 假 unresolved 工单）。两侧先经同一归一化，纯空白差异不再进入
    结论比对。
    """
    return " ".join((text or "").split())


# 生效条件：text 假值时按空串处理，返回去掉全部空白字符（半角/全角空格、\t、\r、\n 等 str.split() 认可的空白）后的字符串；纯空白文本返回空串 "";
def _nows(text):
    """去掉**全部**空白——只用于「两侧差异是不是只在空白」的相等短路判定。

    与 `_norm_ws` 的分工：`" ".join(x.split())` 只折叠空白，**词内插空白**
    （「指数退避重连」↔「指数 退避重连」）归一化后仍非逐字相同（探针实测该情形
    归一化覆盖率 0.9388 < 0.95，仍会判分歧）；去掉空白才判得出「差异只在空白」。

    安全性：它只用于**相等**判定（相等 ⇒ 必判重复），不参与覆盖率计算，故不会
    放宽「同槽不同值 / 同值异措辞」这两类真分歧——它们的差异不是空白，去空白后
    仍不相等，照旧走 `conclusion_overlap < CONCLUSION_SAME` 的判据。
    """
    return "".join((text or "").split())


# 生效条件：content 给出时（假值按空串）解析 CCG 的 `# 功能名` 与 `# 子功能` 字段并返回两者；
def _slot_text(content):
    """结论槽：CCG 声明的 `# 功能名` / `# 子功能`（结构字段，非正文词面）。

    为什么需要它：L1-c 要判「两条记忆是不是在讲同一件事」，但**词面覆盖率测不了**
    ——探针实测「同属性不同值」0.612 vs「异属性」0.623（负样本反超），
    因为两者与旧正文的通用词重合度相当。CCG 的槽位字段才是「在讲哪件事」的
    结构化代理，与 L1-b 用 condition_space 判「在什么条件下」同构。

    字段缺失 → 返回空串 → L1-c 不触发（保守：无从比对时不假装确定）。
    """
    _ccg_field, _declared, _neg_hit, _cov = _prims()
    return (_ccg_field(content or "", "功能名"),
            _ccg_field(content or "", "子功能"))


# --------------------------------------------------------------------------
# 选面支持件（cons200）：相关性预筛打分（快照级，不读盘）
# --------------------------------------------------------------------------

# 生效条件：e 为索引快照条目、nid 为节点 id、tw_pos/tw_neg 为 {词: 权重}（可为空）、cov 为 _weighted_coverage 同签名函数、disc 为 _is_discipline 的预判结果（None 时按 `_is_discipline({"tags": e.get("tags")}, nid)` 现算）——只读条目内既有键（tags/rejection_terms），恒返回整数二元组 (rank, score)：disc 真 → (3, 0)；否则 rejection_terms 与 tw_pos/tw_neg 有整词子串交集 → (2, int(round(1e4 * max(双方 _cov)) ))；仅 rejection_terms 非空 → (1, 0)；其余 → (0, 0)；不读盘、不解析正文、不调用 cg.get。
def _sift_score(e, nid, tw_pos, tw_neg, cov, disc=None):
    """候选相关性打分（**只排序，不裁决**）——选面的廉价预筛单点。

    为什么需要它（cons200 = 设计稿签收点 5）：修前选面是 `list(nodes.items())`
    的索引序前 MAX_SCAN——库超限后 id 字典序靠后的记忆节点（`mem_*`）几乎从不
    进入比对面；而在役库前 200 名基本全是 `code_*` 索引节点。修后按**相关性**
    （本函数）排序取前 limit，让「可能与本内容构成条件冲突」的候选优先入面。

    信号与廉价性（全部来自索引快照既有键，零读盘、零正文解析）：
      · `tags`（`_is_discipline` 单点判纪律）——纪律违反是硬冲突（REJECT），
        且其命中走 `_ban_hit` 子串判定、不走词权，故纪律候选 rank 最高（3）；
      · `rejection_terms`（**不适用条件**的索引词项，由既有单点
        `mdcg.rejection_index_terms` 生成——不新造第二套解析）：与本次内容的
        条件词面（tw_pos/tw_neg）做整词交集（`in` 子串，C 速度），命中即
        rank 2 并以 `_weighted_coverage`（既有单点）给连续分；非空但无交集
        rank 1（弱迹象：该节点确有负条件声明）。
    诚实边界（不假装全知）：本键只覆盖**负条件面**（新正↔旧负 / 新负↔旧负）。
    「旧节点只声明生效条件（e_pos）」的比对面在快照里**无字段可判**（`_declared`
    还要读 content 的 `# 生效条件：` 行）——故本函数不做任何剔除，只做排序；
    信息不足的候选按 rank 0 落在相关面尾部、并在候选超限时由**保底面**
    （修前扫描面 ∩ 候选，逐位保留）兜底，二者共同保证「修后检出 ⊇ 修前」。
    """
    if disc is None:
        disc = _is_discipline({"tags": e.get("tags")}, nid)
    if disc:
        return 3, 0
    rt = e.get("rejection_terms") or []
    if not rt:
        return 0, 0
    blob = " ".join(str(t) for t in rt)
    hit = False
    if tw_pos:
        hit = any(str(t) in blob for t in tw_pos)
    if not hit and tw_neg:
        hit = any(str(t) in blob for t in tw_neg)
    if not hit:
        return 1, 0
    s1 = cov(tw_pos, blob) if tw_pos else 0.0
    s2 = cov(tw_neg, blob) if tw_neg else 0.0
    return 2, int(round(10000 * max(s1, s2)))


# --------------------------------------------------------------------------
# L0 情绪通道（信息差二阶变化）
# --------------------------------------------------------------------------

# 生效条件：conflict_strength 被夹取到 [0,1]（假值按 0.0），prev_strength 为 None 时 d2=0.0，否则 d2=c-夹取后的 prev_strength；按 c>=EMO_AVOID 或 d2>0.2 返回 avoiding，或 c<=EMO_APPROACH 且 d2<=0 返回 approaching，否则 stable；
def emotional_bias(conflict_strength, prev_strength=None):
    """L0：把冲突强度映射为情绪倾向（approaching / stable / avoiding）。

    对齐 `智能的公理化基石.md` §十一：情绪是**信息差的二阶变化** d²D/dt²。
    这里的工程代理：
      · 一阶 d1  = conflict_strength（新信息与既有结构的相斥程度）
      · 二阶 d2  = 本次 d1 − 上次 d1（用留痕里的上一条强度作基线）
      · d2 > 0 → 信息差在**扩大**（越来越不顺）→ avoiding
      · d2 < 0 → 信息差在**收敛**（越来越顺）   → approaching

    重要（原文强制）：情绪通道**独立、不参与信任/资格计算**，
    只影响「是否升级到递归反思」的调度决策。
    """
    c = max(0.0, min(1.0, float(conflict_strength or 0.0)))
    if prev_strength is None:
        d2 = 0.0
    else:
        d2 = c - max(0.0, min(1.0, float(prev_strength)))
    if c >= EMO_AVOID or d2 > 0.2:
        bias = "avoiding"
    elif c <= EMO_APPROACH and d2 <= 0:
        bias = "approaching"
    else:
        bias = "stable"
    return {"bias": bias, "conflict_strength": round(c, 4),
            "d2": round(d2, 4),
            "note": "情绪通道独立，不参与信任/资格计算（智能论 §十一）"}


# 生效条件：p 给出时，若文件可读则逐行**容错**解析（errors="replace" 解码；空行跳过；JSON 解析失败或解析结果不是对象时计入 stats["unparsable"] 并跳过），只产出 dict 行；路径缺失则什么都不产出；OSError 抛给调用方；stats 给出时按 {"unparsable": n} 累加；
def _records(p, stats=None):
    """留痕的**单点**容错读（流式，不载全量）。

    issue #225：`log()` 是 `open(p,"a")` 单行追加，一次写被打断（断电 / 磁盘写满 /
    进程被杀）会把某条留痕截在多字节字符中间。旧读取器用严格 utf-8 解码，
    `UnicodeDecodeError`（ValueError 子类）在 `for line in f` 处抛、内层
    `except (ValueError, TypeError)` 管不到，外层只接 OSError ⇒ 此后每次
    `check()`（写热路径，:804 无条件取上一条强度）与 `summary()`（cg(op=info)
    健康面）都报错：新记忆静默全丢、重启不恢复，而 heal 报 ok。

    容错口径与 `fsutil.read_jsonl` 一致（`errors="replace"` ＋ 坏行跳过），差别只在
    **多一个 `unparsable` 计数**：诊断面必须看得见「台账里有几条读不出来」，不能把
    读不出来的行当作不存在。非对象行（`null` / `[1]`）一并计入——它们在 `.get(...)`
    处抛 AttributeError，与坏行同属「读不出来」。
    """
    if not os.path.exists(p):
        return
    with open(p, "r", encoding="utf-8", errors="replace") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                r = json.loads(line)
            except ValueError:
                r = None
            if not isinstance(r, dict):
                if stats is not None:
                    stats["unparsable"] = stats.get("unparsable", 0) + 1
                continue
            yield r


# 生效条件：cg 给出时，若 cg.root 下 LOG_FILE 可读，则容错逐行解析（坏行/非对象行跳过，见 _records）并取 .get("conflict_strength")，返回最后一条可解析记录的该键值（缺键或值为 None 则为 None）；路径不可用或 OSError 返回 None；
def _last_strength(cg):
    """上一条留痕的冲突强度（二阶差分的基线）。流式读，不载全量。"""
    p = os.path.join(cg.root, LOG_FILE)
    if not os.path.exists(p):
        return None
    last = None
    try:
        for r in _records(p):
            last = r.get("conflict_strength")
    except OSError:
        return None
    return last


# --------------------------------------------------------------------------
# L2 递归反思（受深度 / 节点数 / 循环 / 增益门槛约束）
# --------------------------------------------------------------------------

# 生效条件：以 cg 节点图、seeds 中非空项为初始 frontier、tw_pos/tw_neg 词权，在 d=1..int(max_depth) 每轮先判 frontier 空返回 frontier_exhausted，再逐 nid 处理时若 nodes_visited > max_nodes 先返回 node_budget（可先于同层 discriminators），否则处理完该层后有 discriminators（非 seed_set 节点声明条件与 tw_pos/tw_neg 覆盖 >= CLASH_HIGH）返回 resolved，否则 last_gain < min_gain 返回 gain_below_threshold，循环耗尽返回 depth_exceeded；seeds 假值时 frontier 空，在 d=1 进入循环后由首判返回 frontier_exhausted（depth=0）；
def _recursive_reflect(cg, seeds, tw_pos, tw_neg, max_depth=MAX_DEPTH,
                       max_nodes=MAX_NODES, min_gain=MIN_GAIN):
    """递归反思：沿关系链找「区分条件」，每层检查信息增益。

    收敛条件（任一）：
      · 找到区分节点（其负条件排除新节点 / 其正条件与新负条件互斥）→ resolved
      · 增益 < min_gain（候选空间没变少，继续搜没意义，对齐 :273）→ 停
      · 超深度 / 超节点预算 / 循环检测命中 → 停
    """
    _ccg_field, _declared, _neg_hit, _cov = _prims()
    seed_set = {s for s in (seeds or []) if s}
    visited, frontier, trace = set(), [s for s in (seeds or []) if s], []
    nodes_visited, last_gain = 0, 0.0
    for d in range(1, int(max_depth) + 1):
        if not frontier:
            return {"resolved_by": [], "depth": d - 1, "nodes_visited": nodes_visited,
                    "gain": last_gain, "stopped_by": "frontier_exhausted",
                    "trace": trace}
        nxt, discriminators = [], []
        for nid in frontier:
            if nid in visited:
                continue
            visited.add(nid)
            nodes_visited += 1
            if nodes_visited > max_nodes:
                return {"resolved_by": [], "depth": d,
                        "nodes_visited": nodes_visited, "gain": last_gain,
                        "stopped_by": "node_budget", "trace": trace}
            node = cg.get(nid) or {}
            fm = node.get("frontmatter") or {}
            body = node.get("content") or ""
            e_pos, e_neg = _declared(fm, body)
            # 冲突源（seed）自身不是区分条件：它与新节点的关系正是待分辨的
            # 冲突本身，把它当作「已分辨」会制造假的 resolved（增益虚高 1.0）。
            if nid not in seed_set:
                if e_neg and tw_pos and _cov(tw_pos, " ".join(e_neg)) >= CLASH_HIGH:
                    discriminators.append(nid)
                elif e_pos and tw_neg and _cov(tw_neg, " ".join(e_pos)) >= CLASH_HIGH:
                    discriminators.append(nid)
            nxt.extend(_edge_targets(fm))
        if discriminators:
            return {"resolved_by": discriminators, "depth": d,
                    "nodes_visited": nodes_visited, "gain": 1.0,
                    "stopped_by": "resolved", "trace": trace}
        fresh = [x for x in dict.fromkeys(nxt) if x not in visited]
        last_gain = len(fresh) / float(max(1, len(frontier)))
        trace.append({"depth": d, "frontier": len(frontier),
                      "fresh": len(fresh), "gain": round(last_gain, 4)})
        if last_gain < min_gain:
            return {"resolved_by": [], "depth": d, "nodes_visited": nodes_visited,
                    "gain": round(last_gain, 4),
                    "stopped_by": "gain_below_threshold", "trace": trace}
        frontier = fresh
    return {"resolved_by": [], "depth": int(max_depth),
            "nodes_visited": nodes_visited, "gain": round(last_gain, 4),
            "stopped_by": "depth_exceeded", "trace": trace}


# --------------------------------------------------------------------------
# 主入口：三级决策
# --------------------------------------------------------------------------

#: issue #193：写入面的**额外比对层**——写入路径把「写到哪层」当成了「只和哪层
#: 比」。contextual 是 gated 写入的缺省层：同层无节点即判 BLINDSPOT「既有节点均
#: 未声明条件」、同层只有无关节点即判 ACCEPT「无冲突」，而同一内容写 knowledge
#: 层判 DEFER——同条件同结论槽的矛盾取值因此被静默放过（实测 20 条写 contextual
#: 全部漏判）。只扩**写入面**（`write_scope=True`）：只读 `mdcg_consistency
#: action=check` 与 auditview 不传该参数，比对范围逐位不变。方向**单向**——
#: knowledge 不回看 contextual，structural/anchor 不纳入（层权限与保护语义不同，
#: 不混比）。
#:
#: 值守返工判据（PR #316 审阅 #193，2026-10-15）：旧分支 b4d8af8 的无条件
#: 形态（层过滤器 `!= layer and != "knowledge"`）实测两条失效——①「指定层
#: 比对」的只读面也被拉动（词面无交集的不相关 knowledge 在场即
#: BLINDSPOT→ACCEPT、comparable 0→1，同层语义被跨层改写）；②层外候选与
#: 同层候选**同池竞争**相关面前 limit，limit 压力下同层矛盾节点被挤出精比面
#: （实测 limit=1 丢同层检出）。故扩面必须「显式请求（write_scope）＋另道
#: 收集」；阴性判据（不相关 knowledge 不引发跨层误报）与位移判据的守卫见
#: test_issue193_consistency_knowledge_guard.py。
EXTRA_SCOPE = {"contextual": ("knowledge",)}

# 生效条件：以 cg.index.nodes 为既有节点、content（假值按 ""）经 _new_terms 得 pos/neg 并算 tw_pos/tw_neg（non_applicable_conditions 入参先按 mdcos._is_null_condition 剔除空值语义哨兵——⑧ 漏斗单点）；选面（cons200；收口轮①加面内例外）：快照级预筛剔「tw_pos 与 tw_neg 皆空（新节点未声明可展开条件）时的非纪律候选」，且**剔除永不触及修前扫描面**（过滤后索引序前 limit 内候选一律保留——面内例外；「修后检出 ⊇ 修前」由此成为与「条目 tags 与盘面 fm.tags 同源」无关的结构保证），候选超 limit 时精比面＝相关面（_sift_score 序）前 limit ∪ 保底面（过滤后索引序前 limit ∩ 候选），记 scanned（候选数）/kept（精比数）/truncated（scanned>kept）/hint；按循环中 hard（自否定或纪律命中）→ divergences（同条件槽且 concl < CONCLUSION_SAME）→ strength ≥ CLASH_HIGH → strength ≥ CLASH_LOW → comparable==0 且 (pos or neg) → 否则 ACCEPT 的顺序定 verdict；DEFER 且 int(depth)>0 且 emo["bias"] != "approaching" 时调 _recursive_reflect 补 recursion，auto_flywheel 且 verdict == REJECT 时加 unresolved_id，最后 log_write 为真时 log 并返回 rec；N276：精比环的索引代际探针循环外单探一次（环内 cg.get(nid, probe=False)；无探活面载体回落 cg.get(nid)，行为零变）；
def check(cg, content, layer=None, condition_space=None,
          non_applicable_conditions=None, tags=None, exclude=None,
          limit=MAX_SCAN, depth=MAX_DEPTH, auto_flywheel=False,
          query=None, log_write=True, write_scope=False, sensitivity=None):
    """节点间自动冲突检测（L0 情绪 → L1 反思 → L2 递归反思）。

    返回完整判据（可审计）：
      verdict / reason / conflict_strength / emotional / conflicts[] /
      recursion{} / missing[] / unresolved_id（若触发飞轮）

    log_write（路线 C，2026-10-05）：缺省 True＝原行为（每次判定落
    `_consistency.jsonl` 台账）。**只读消费面**（如 auditview 的证据审计）
    须传 False——判定结果照常返回，但不写台账（复核 R2：审计面承诺
    「纯读、不落盘」，而 log() 无条件追加会把每次审计变成一次写）。
      + 选面读数 scanned / kept / truncated（超限时另附 hint）

    verdict：
      ACCEPT    无冲突，或新节点未声明条件（无从冲突）
      REJECT    硬冲突：自否定 / 违反纪律
      DEFER     条件互斥但可能可分辨（交给 L2 递归或飞轮）
      BLINDSPOT 有条件声明，但既有节点全无声明 → 无法建立比对路径（不假装确定）

    选面（cons200，2026-10-03；设计稿签收点 5；收口轮① 2026-10-03 复核 DEFER
    后补面内例外）：
      修前＝`list(nodes.items())` 索引序前 limit 并静默 break——在役库 17882
      节点 ⇒ 检测面 ≈1.1%，且 id 字典序最前的 `code_*` 索引节点占满名额，
      真正相关的记忆节点（`mem_*` 在字典序后段）几乎从不进入比对面。修后：
        · 预筛（快照级、不读盘）：`_sift_score` 只读条目既有键（tags /
          rejection_terms），不做任何「信息不足」的剔除——唯一剔除档是
          **可证无产出且不落修前扫描面**的「tw_pos 与 tw_neg 皆空（新节点
          未声明可展开条件）时的非纪律候选」（c1/c2/c3/c4 与分歧判定都以
          tw_* 为乘子；纪律违反走 `_ban_hit` 子串、不看词权，故纪律候选
          一律保留）。**面内例外（收口轮①）**：剔除永不触及修前扫描面
          （过滤后索引序前 limit 内的候选一律保留进候选面、落保底面通道）
          ——「修后检出 ⊇ 修前」由此是**与「条目 tags 与盘面 fm.tags
          同源」无关的结构保证**（预筛读条目快照，而精比读盘面；非同源
          形态下面内候选仍进精比面、由盘面判据裁决）。
        · 精比面＝相关面（`_sift_score` 序）前 limit ∪ **保底面**（过滤后
          索引序前 limit ∩ 候选，逐位＝修前扫描面）。保底面不撤是「修后检出
          ⊇ 修前」的结构性保证（不是概率）；两面上限各 limit（≤2×MAX_SCAN）。
        · 可观测：`scanned`＝预筛后候选数、`kept`＝实际精比数、`truncated`＝
          scanned>kept（截断不静默，附 `hint` 给细化条件/建索引方向）。
      诚实边界：`rejection_terms` 只覆盖负条件面；「旧节点仅声明生效条件
      （e_pos，正文 `# 生效条件：` 行）」在快照里无字段可判——此类节点在
      候选超限且不落修前扫描面/保底面时可能不被精比（由 truncated 可观测）。
      非同源形态（盘面 tags 与索引条目不一致，非合作写者直改 `.md` 不重建
      索引）分两段：**面内**（修前扫描面内）由面内例外保底、精比读盘面判定，
      修后仍检出（不构成回归）；**面外残余**（条目未标纪律、盘面是纪律的
      节点落修前扫描面之外）修前亦检不出——不构成回归，属已声明边界
      （预筛读索引条目快照；协作写者协议下条目与盘面同源，无此形态）。
      判据本身（阈值/覆盖计算/四态路由）一字未动——本批只改**选面与读数**。

    non_applicable_conditions（⑧ 2026-09-30）：入参里的**空值语义哨兵**
    （`["无"]` / `["（无）"]` / `[""]`…）在本函数的入口统一剔除——判据复用
    `mdcos._is_null_condition`（仓内唯一哨兵表，**不新造第二张词表**）。
    为何修在漏斗而不是各调用点：本函数是全部入口（`MdCG.add` 直连
    `consistency.check`、`MdCGOS.check_consistency`、写链 `writepipe`）的
    公共汇聚点，只改一处即可同口径；此前 mdcos 侧已在 `_ccg_field` 与
    `check_consistency` 两处收口，而 `mdcg.add` 的直连绕过了它们——实测
    `consistency.check(..., non_applicable_conditions=["无"])` 仍判
    DEFER/strength=1.0 并建工单，同一内容不传该列表则 ACCEPT/0.0/无工单。
    """
    _ccg_field, _declared, _neg_hit, _cov = _prims()
    content = content or ""
    # ⑧：哨兵剔除单点（延迟导入，避免 consistency ← mdcos 的模块级循环依赖）。
    if non_applicable_conditions:
        from .mdcos import _is_null_condition
        non_applicable_conditions = [x for x in non_applicable_conditions
                                     if not _is_null_condition(x)]
    pos, neg = _new_terms(content, condition_space, non_applicable_conditions)
    tw_pos = expand_query_terms_weighted(" ".join(pos)) if pos else {}
    tw_neg = expand_query_terms_weighted(" ".join(neg)) if neg else {}
    # 结论文本词权（结论比对专用）：**去 CCG 声明行**。模板行（`# 功能名` /
    # `# 子功能` 等）在两条节点间逐字相同，若混入会稀释结论覆盖率——实测
    # 逐字重复仅 0.32、同槽不同值 0.61，与异属性（0.62）不可分。去模板才可判。
    # H8：**两侧还要同一套空白归一化**（`_norm_ws`）——见 `_norm_ws` 说明；
    # `n_body_tight` 是去全部空白的形态，只供 L1-c 的相等短路用。
    # issue #88：结论比对改看**结论面**（不再看被剥空的正文）——`_body_text` 仍归
    # 自否定面单独使用（见下方那处独立调用），两处口径互不牵连。
    n_concl = _conclusion_text(content)
    tw_content = expand_query_terms_weighted(_norm_ws(n_concl)) if n_concl else {}
    n_body_tight = _nows(n_concl)
    # 结论槽（CCG 结构字段）——「是否同一件事」的代理，见 _slot_text 说明
    n_fn, n_sb = _slot_text(content)

    conflicts, hard, divergences = [], [], []
    strength, comparable = 0.0, 0

    # ---- L1-a 自否定：自己的负条件排除自己的生效条件/正文 ----
    body = _body_text(content)
    _hit_pos = bool(neg) and _ban_hit(" ".join(pos), neg)
    _hit_body = bool(neg) and _ban_hit(body, neg)
    if _hit_pos or _hit_body:
        # issue #669②：两个面压成同一句话会让作者看不出「到底哪一面命中」——
        # 判据（type/score）不变（N238 契约：正文面命中仍是 self_negation 1.0），
        # 只把命中面写进 detail，可诊断性提升不改变任何行为。
        _face = ("生效条件面" if _hit_pos else "") + ("+正文面" if _hit_body else "")
        hard.append({"type": "self_negation", "with": None,
                     "detail": "不适用条件命中自身%s：条件自相矛盾（命中面=%s）"
                               % ("生效条件/正文" if (_hit_pos and _hit_body) else "正文", _face),
                     "score": 1.0})
        strength = 1.0
    elif neg and _cov(tw_neg, body) >= SELF_NEGATION:
        hard.append({"type": "self_negation", "with": None,
                     "detail": "负条件与正文强相关：条件与结论互斥",
                     "score": round(_cov(tw_neg, body), 4)})
        strength = max(strength, round(_cov(tw_neg, body), 4))

    # ---- L1-b 与既有节点的条件级比对（反题） ----
    # 选面（cons200）：预筛（快照级，不读盘）→ 候选面 → 相关面 ∪ 保底面。
    nodes = ((getattr(cg, "index", None) or {}).get("nodes") or {})
    order = list(nodes.items())          # 索引序快照（H-4(a)：快照后再迭代）
    lim = max(0, int(limit))
    no_terms = not (tw_pos or tw_neg)    # 新节点未声明可展开条件
    # issue #193：写入面的层外候选层集合。新内容未声明可展开条件时不扩面
    # （自由正文 #68 自动记忆面无条件可依，扩面只会空转）。
    extra_layers = (EXTRA_SCOPE.get(layer, ())
                    if (write_scope and layer and not no_terms) else ())
    extra_cand = []                      # (rank, score, False, nid, e)
    cand = []                            # (rank, score, in_before, nid, e)
    fidx = 0
    for nid, e in order:
        if exclude and nid == exclude:
            continue
        if layer and e.get("layer") != layer:
            # issue #193：层外候选**另道收集**（不进相关面/保底面，故同层
            # 检出逐位不变），排序取前 limit 后并入精比面。
            if e.get("layer") in extra_layers:
                _d = _is_discipline({"tags": e.get("tags")}, nid)
                _r, _s = _sift_score(e, nid, tw_pos, tw_neg, _cov, _d)
                extra_cand.append((_r, _s, False, nid, e))
            continue
        in_before = fidx < lim           # 是否在修前扫描面内（过滤后前 limit）
        fidx += 1
        disc = _is_discipline({"tags": e.get("tags")}, nid)
        # 预筛唯一剔除档（可证无产出；其余一律放行——宁多勿漏）：
        # 新节点未声明可展开条件时，非纪律候选在精比中产出恒为零
        # （c1/c2/c3/c4 与分歧判定均以 tw_pos/tw_neg 为乘子；纪律违反走
        # _ban_hit 子串判定、不看词权——故纪律候选一律保留）。
        # 收口轮①（复核 DEFER 后补）：**面内例外**——剔除永不触及修前扫描面
        # （过滤后索引序前 limit 内的候选一律保留），使「修后检出 ⊇ 修前」
        # 成为与「条目 tags 与盘面 fm.tags 同源」无关的结构保证（非同源形态
        # 下面内候选仍进精比面、由盘面判据裁决）；面外非同源残余见 check
        # docstring 诚实边界（修前亦检不出，不构成回归）。
        if no_terms and not disc and not in_before:
            continue
        rank, score = _sift_score(e, nid, tw_pos, tw_neg, _cov, disc)
        cand.append((rank, score, in_before, nid, e))
    # 相关面：rank/score 降序 + id 升序终键（确定性；与索引物理序无关）
    rel = sorted(cand, key=lambda t: (-t[0], -t[1], str(t[3])))[:lim]
    rel_ids = {t[3] for t in rel}
    # 精比面＝相关面 ∪ 保底面（过滤后索引序前 limit ∩ 候选）——保底面即
    # 修前扫描面；其逐位保留是「修后检出 ⊇ 修前」的结构性保证（非概率），
    # 两面上限各 limit（≤ 2×MAX_SCAN；MAX_SCAN 数值未动）。
    scan_face = [(t[3], t[4]) for t in rel]
    scan_face += [(t[3], t[4]) for t in cand if t[2] and t[3] not in rel_ids]
    # issue #193：写入面的层外候选独立排序取前 lim，**不碰保底面**——保底面
    # 与相关面都不含层外节点，同层检出因此逐位不变（≤ 3×MAX_SCAN）。
    scan_face += [(t[3], t[4]) for t in
                  sorted(extra_cand, key=lambda t: (-t[0], -t[1], str(t[3])))[:lim]]
    scanned = len(cand)                  # 预筛后候选数（截断前）
    kept = len(scan_face)                # 实际精比数（相关面 ∪ 保底面）
    truncated = scanned > kept
    # N276（第 32 轮性能面）：scan_face 精比环内逐节点 cg.get 会各自重探索引
    # 签名（快照 stat + listdir + 逐分片 stat，单点实测 ~38µs；本库 200 次/
    # 调用全同签名重探）——循环外单探一次、环内 probe=False；语义窗口=本批
    # 读期间「他进程写入」不被探知（毫秒级，与读缓存已声明的进程内一致性
    # 边界同款）；无探活面的载体（测试桩）回落原 cg.get(nid)，行为零变。
    _probe = getattr(cg, "_maybe_reload_index", None)
    if callable(_probe):
        _probe()
    else:
        _probe = None
    # issue #298：调用方未声明密级、而 exclude 指向一个**已存在**的节点时，
    # 该节点当前盘面密级就是本次写入将继承的档位（#297 的继承语义）——
    # 不取它，一条「未声明」地改写既有 private 节点的写入会把新条件原文
    # 按 internal 明文记进台账（`writepipe` 的 `cg(op=write)` 动词面走这条路）。
    # 只在声明值缺失时查一次；查不到（新节点 / 桩无 get）即维持 internal 零变化。
    if not sensitivity and exclude:
        try:
            _en = cg.get(exclude) or {}
            sensitivity = (_en.get("frontmatter") or {}).get("sensitivity") or None
        except Exception:
            sensitivity = None
    # nid → 该既有节点盘面密级（只对**真进了判定**的节点取值，见下）
    ledger_sens = {}
    seeds = []
    for nid, e in scan_face:
        node = (cg.get(nid, probe=False) if _probe is not None
                else cg.get(nid)) or {}
        fm = node.get("frontmatter") or {}
        body = node.get("content") or ""
        # issue #298：`only_existing_side` / `pos_side` / `neg_side` 记的是
        # **这个**节点的条件原文，故它自己的密级必须进落账密级面。
        # 此处只登记不裁决——真正进密级面的只有「进了 allc 的那些节点」，
        # 否则库里任意一个 private 节点被扫到就会把所有台账记录抹成去原文
        # （过度抹除同样是有损：台账的可审计性会整体消失）。
        ledger_sens[nid] = fm.get("sensitivity")
        e_pos, e_neg = _declared(fm, body)
        if not e_pos and not e_neg:
            continue
        comparable += 1
        # 纪律违反：**正文行为**命中纪律节点的不适用条件（negative.reject）
        # —— 这是「不能违反纪律」，与「条件互斥」是两回事：前者看做了什么，
        #    后者看声明的条件是否互相覆盖。
        if _is_discipline(fm, nid) and e_neg and _ban_hit(content, e_neg):
            hard.append({"type": "discipline", "with": nid,
                         "with_layer": e.get("layer"),
                         "detail": "命中纪律节点的不适用条件（negative.reject）",
                         "score": 1.0})
            seeds.append(nid)
            strength = 1.0
            continue
        c1 = _cov(tw_pos, " ".join(e_neg)) if (tw_pos and e_neg) else 0.0
        c2 = _cov(tw_neg, " ".join(e_pos)) if (tw_neg and e_pos) else 0.0
        c = max(c1, c2)
        # issue #216（2026-10-13 DSH 端）：**c1/c2 分路读数**。判据一字未动——
        # c 仍 = max(c1, c2)、阈值与比较算子逐位不变；本改动只让「是哪一侧撑起
        # 这次 condition_clash、两侧各自的词项规模是多少」变成可读读数。
        # 为什么必须分路（issue #216 §〇 自己给出的教训）：c1/c2 是**两个独立事实**
        # 的乘积——「既有节点是否声明了真 na」×「新节点词面是否覆盖该 na」。
        # 只回传合并后的 c，读者无法判断改变的是哪一侧，读数不可复核。
        # 词项规模一并回传，是因为稀释的方向取决于**哪一侧是合成串**：
        # 新节点的 tw_pos 含 condition_space_text 合成串（词项多），而 tw_neg 只来自
        # na（词项少）；既有节点侧则取决于它实际声明的写法。
        clash_routes = {
            "c1": round(c1, 4), "c2": round(c2, 4),
            "terms": {"tw_pos": len(tw_pos or {}), "tw_neg": len(tw_neg or {}),
                      "e_pos": len(e_pos or []), "e_neg": len(e_neg or [])}}
        # ---- L1-c 同侧比对：第二类关系（同条件空间 · 结论槽取值分歧） ----
        # 与 L1-b 是**两类不同关系**：L1-b 判「条件互斥」（反题），L1-c 判
        # 「同条件 + 同结论槽 + 取值不同」（矛盾）。后者在词面不可判——探针实测
        # 「同属性不同值」正文覆盖率 0.612 反而低于「异属性」0.623，故改用
        # CCG 槽位字段代理「是否同一件事」。
        # 注：必须是独立 if 而非 else——同条件时 c=0，若并入 L1-b 分支会被
        #     `c < CLASH_LOW: continue` 提前跳过，L1-c 永不执行。
        c3 = _cov(tw_pos, " ".join(e_pos)) if (tw_pos and e_pos) else 0.0
        c4 = _cov(tw_neg, " ".join(e_neg)) if (tw_neg and e_neg) else 0.0
        same_cond = max(c3, c4)
        # issue #215（2026-10-13 DSH 端）：**两侧口径差回传**。判据一字未动——
        # same_cond 仍 = max(c3, c4)、阈值与比较算子逐位不变；本改动只让
        # 「新节点侧与既有节点侧各自解析出了什么」变成可读读数。
        # 为什么必须回传（issue #215 的核心）：两侧**不同源**——新节点侧
        # _new_terms 并入 condition_space 各槽值，既有节点侧 _declared_conditions
        # 不读 condition_space。故 c3 的分子含槽值、分母不含，覆盖率被系统性压低。
        # 此前回传体里只有合并后的 same_condition 一个数，读者**无法判断**这个数
        # 低是因为「两侧真的不同条件」还是因为「两侧口径不同源」。
        # 与 #216/#217/#218 同一条教训：合成读数必须分路/附来源，否则不可复核。
        cond_routes = {
            "c3": round(c3, 4), "c4": round(c4, 4),
            "new_side": len(pos or []), "existing_side": len(e_pos or []),
            "only_new_side": [x for x in (pos or []) if x not in (e_pos or [])][:6],
            "only_existing_side": [x for x in (e_pos or []) if x not in (pos or [])][:6],
            "same_source": list(pos or []) == list(e_pos or [])}
        if same_cond >= SAME_COND_HIGH:
            e_fn, e_sb = _slot_text(body)
            # issue #217（2026-10-13 DSH 端）：**逐路先算再取 max**。判据一字未动
            # ——slot 仍 = max(两路)，裁决结果逐位不变；本改动只把「是哪一路撑起了
            # 槽位」变成可读读数。此前只回传合并后的 slot，读者无法判断「仅一路相符」，
            # 该失效是静默的（回传体里没有任何字段记录它）。
            # 与 #216 同一条教训：c1/c2、槽位这类**多路合成**读数，回传面必须分路，
            # 否则读数不可复核。
            slot_fn = (_cov(expand_query_terms_weighted(n_fn), e_fn)
                       if (n_fn and e_fn) else 0.0)
            slot_sb = (_cov(expand_query_terms_weighted(n_sb), e_sb)
                       if (n_sb and e_sb) else 0.0)
            slot = max(slot_fn, slot_sb)
            if slot >= SLOT_HIGH:
                # 同口径比对（正文↔正文）：tw_content 已去模板行 + 两侧同一套
                # 空白归一化（H8），见上文与 `_norm_ws` / `_nows` 说明。
                e_body = _conclusion_text(body)      # issue #88：同看结论面
                if n_body_tight == _nows(e_body):
                    # 差异只在空白（\r\n↔\n / 缩进 / 空行 / 行尾空格 / 词内插空白）
                    # ⇒ 归一化后逐字相同，必判**重复**（该合并），不判分歧。
                    # 这不是放宽真分歧判据：取值真不同者去空白后仍不相等。
                    concl = 1.0
                else:
                    concl = (_cov(tw_content, _norm_ws(e_body))
                             if tw_content else 0.0)
                    # issue #189：单向覆盖只量「新结论的词在旧结论里出现多少」，
                    # 新结论整段是旧结论的子串时恒为 1.0（「启用缓存」⊂「不启用缓存」、
                    # 「超时 30」⊂「超时 300」）⇒ 删掉否定词或末位数字被当成重复。
                    # 判「相同」须两个方向都达标：正向已达标时再反向量一次取小值；
                    # 正向未达标时不算反向，既有 conclusion_overlap 读数一字不变。
                    if concl >= CONCLUSION_SAME:
                        concl = min(concl, _cov(
                            expand_query_terms_weighted(_norm_ws(e_body)),
                            _norm_ws(n_concl)))
                if concl < CONCLUSION_SAME:
                    divergences.append({
                        "type": "same_condition_divergence", "with": nid,
                        "with_layer": e.get("layer"),
                        "detail": ("适用条件与结论槽均重合但取值不同："
                                   "可能是更正（应覆盖旧值）或分歧（应补区分条件）"),
                        "same_condition": round(same_cond, 4),
                        "cond_routes": cond_routes,
                        "slot_overlap": round(slot, 4),
                        "slot_routes": {"功能名": round(slot_fn, 4),
                                        "子功能": round(slot_sb, 4)},
                        "slot_single_route": (
                            (slot_fn >= SLOT_HIGH) != (slot_sb >= SLOT_HIGH)),
                        "conclusion_overlap": round(concl, 4),
                        "slot": (n_fn or n_sb)[:40]})
        if c < CLASH_LOW:
            continue
        conflicts.append({
            "type": "condition_clash", "with": nid,
            "with_layer": e.get("layer"),
            "detail": ("新节点适用条件落在既有节点不适用区"
                       if c1 >= c2 else "新节点不适用条件覆盖既有节点适用区"),
            "score": round(c, 4),
            "clash_routes": clash_routes,
            "pos_side": _dedup(pos)[:6], "neg_side": _dedup(neg)[:6]})
        seeds.append(nid)
        strength = max(strength, c)

    # ---- L0 情绪（独立通道，只调度不裁决） ----
    emo = emotional_bias(strength, _last_strength(cg))

    # ---- L1 四态判定 ----
    recursion = None
    missing = []
    allc = hard + conflicts + divergences
    if hard:
        verdict = "REJECT"
        reason = "；".join(h["detail"] for h in hard)
    elif divergences:
        verdict = "DEFER"
        reason = "同条件空间下结论槽取值不一致：需裁决是更正还是分支"
        # 两类关系可能同时成立（同条件分歧 vs 某节点 / 条件互斥 vs 另节点）——
        # 若只报 L1-c，跨侧互斥的线索会被掩盖，飞轮收到的缺口就不完整。
        if conflicts:
            reason += f'；另有 {len(conflicts)} 处条件互斥（见 conflicts[]）'
    elif strength >= CLASH_HIGH:
        verdict = "DEFER"
        reason = "条件互斥：需补区分条件后才能判定"
    elif strength >= CLASH_LOW:
        verdict = "DEFER"
        reason = "条件部分覆盖：待确认是否互斥"
    elif comparable == 0 and (pos or neg):
        verdict = "BLINDSPOT"
        reason = "既有节点均未声明条件：无法建立比对路径"
    else:
        verdict = "ACCEPT"
        reason = "无冲突"

    # ---- L2 递归反思（仅对 DEFER，且情绪未指向 approaching 的快速放行） ----
    if verdict == "DEFER" and int(depth) > 0 and emo["bias"] != "approaching":
        recursion = _recursive_reflect(cg, seeds, tw_pos, tw_neg,
                                       max_depth=int(depth))
        if recursion["stopped_by"] == "resolved":
            # 找到区分条件 → 冲突可分辨，降级为待定（不直接放行，留人工/飞轮）
            reason = f'{reason}；已找到区分条件 {recursion["resolved_by"][:3]}'
        else:
            missing.append({
                "need": "区分条件",
                "why": f'递归反思停止于 {recursion["stopped_by"]}'
                       f'（深度 {recursion["depth"]}，增益 {recursion["gain"]}）',
                "conflicts": [c["with"] for c in allc if c.get("with")][:5]})

    # 同条件取值分歧：L2 递归提供不了「区分条件」（条件本就相同），故缺口是
    # 「取值裁决」而非「补条件」。必须显式落 missing——否则 strength=0 →
    # 情绪 approaching → L2 被跳过 → missing 为空 → 飞轮收不到任何信号。
    if divergences and not missing:
        missing.append({
            "need": "取值裁决（同条件·同结论槽存在多条不同取值）",
            "why": "适用条件与结论槽均重合而正文取值不同："
                   "需确认是「更正」（应覆盖旧值）还是「分支」（应补区分条件）",
            "conflicts": [c["with"] for c in allc if c.get("with")][:5]})

    rec = {"t": time.time(), "layer": layer, "verdict": verdict,
           "reason": reason, "conflict_strength": round(strength, 4),
           "emotional": emo, "conflicts": allc, "recursion": recursion,
           "missing": missing, "scanned": scanned, "kept": kept,
           # issue #193：实际参与比对的层集合（层外候选并入时非单层；
           # 未传 layer 时为 None）。
           "scope_layers": ([layer] + list(extra_layers)) if layer else None,
           "truncated": truncated, "comparable": comparable,
           "pos": pos[:6], "neg": neg[:6],
           "actor": getattr(cg, "actor", "unknown")}
    # 截断不静默（与 stg `_with_scan_reads` 同口径精神）：候选有被限额丢弃时
    # 附可操作 hint（细化条件 / 建索引方向；数值放大不是修法）。
    if truncated:
        rec["hint"] = _SCAN_HINT % (scanned, lim, kept)

    # ---- 冲突自动触发飞轮（误差 → 补条件 → 结构更新） ----
    # H11⑥（2026-09-30）：**只对真冲突（REJECT）建单**。改前是
    # verdict ∈ {REJECT, DEFER, BLINDSPOT} 全建——DEFER 是「条件互斥/部分覆盖、
    # 待确认」，BLINDSPOT 是「既有节点全无声明 ⇒ 检测前提不存在」（空库上恒
    # 成立），两者都不是「已判定的冲突」；自动落 unresolved 等于把待确认与
    # 无法比对写成待办工单，污染裁决队列（实测：哨兵形态误判出的 DEFER 直接
    # 固化成工单 unr_9af4803381）。判定本身照旧返回，缺的只是「自动建单」。
    if auto_flywheel and verdict in FLYWHEEL_TRIGGERS:
        rec["unresolved_id"] = _fire_flywheel(cg, query or content, verdict,
                                              reason, missing, allc,
                                              sensitivity=sensitivity)
    if log_write:
        # issue #298：**落盘副本**带密级、按需去原文；上面返回给调用方的
        # `rec` 一字不动（复核面要原文，落盘面不要）。
        _lv = [ledger_sens.get(x) for x in
               {c.get("with") for c in allc if isinstance(c, dict)}]
        _lvl = ledger_level(sensitivity, _lv)
        _lr = _level_rank(_lvl)
        _redact = bool(_lr is not None and _lr > _LEDGER_LEVELS.index("internal"))
        _rec = scrub_record(rec) if _redact else dict(rec)
        _rec["sensitivity"] = _lvl
        _rec["redacted"] = _redact
        log(cg, _rec)
    return rec


# 生效条件：missing 为假值（None/空容器/空串）返回 ""；否则逐项仅取 m.get("need") 真值者，格式化为 `need（why）`（why 缺键用 ""，值为 None 则格式化 "None"），以 "；" 连接；
def _missing_text(missing):
    """结构化缺口 → 一行文本（作为飞轮的 known_clues）。

    修断链：原先 `missing=reason` 把「人话」塞进 missing 字段，而判定器产出的
    [{need, why, conflicts}] 落在 detail 里被 flywheel_step 忽略——上游产出了
    结构，却送不到下游。
    """
    if not missing:
        return ""
    return "；".join(f'{m.get("need", "")}（{m.get("why", "")}）'
                    for m in missing if m.get("need"))


# 生效条件：cg 提供可调用 flywheel_step 时，以 query（假值按空串）前 200 字符、verdict、missing 文本（空则 reason）、conflicts 非 None 则作 detail 否则 missing 调用；结果 dict 的 unresolved_id 真值时返回它，否则回落 id；无 flywheel_step 或异常返回 None；
def _fire_flywheel(cg, query, verdict, reason, missing, conflicts=None,
                   sensitivity=None):
    """把冲突作为「误差」投给知识飞轮，返回 unresolved 条目 id（失败不阻塞写入）。

    conflicts：冲突现场（含 type / with / same_condition 等数值），供下游生成
    `# 现场：`；missing 只描述「缺什么」，不带现场，故两者都要送。

    sensitivity（issue #294，2026-10-14）：调用方写入声明的密级，**必须**随
    payload 下传。修前这里不带密级，而 payload 里的 query 是**调用方正文前
    200 字**，飞轮据此建的 unresolved 工单便按缺省档 internal 明文落盘——
    一条声明 private 的写入被判 REJECT 后，属主只收到「冲突拒绝」，正文却
    以明文出现在工单正文、**文件名（id 由正文 slug 而来）**、`_index.json`
    与 `_audit.jsonl` 里，无令牌访客 `stg(op=timeline)` + `mdcg_get` 即可读。
    缺省 None＝调用方未声明（按 #87 走 internal，零变化）。
    """
    step = getattr(cg, "flywheel_step", None)
    if step is None:
        return None
    try:
        payload = {"query": (query or "")[:200], "expected_state": "ACCEPT",
                   "actual_state": verdict,
                   "missing": _missing_text(missing) or reason,
                   "detail": conflicts if conflicts is not None else missing}
        # 只在真声明时带键：缺省写入面（未声明密级）payload 逐键不变，
        # 下游 flywheel_step 的缺键回落路径照旧。
        if sensitivity:
            payload["sensitivity"] = sensitivity
        r = step(payload)
        if isinstance(r, dict):
            return r.get("unresolved_id") or r.get("id")
    except Exception:
        return None
    return None


# --------------------------------------------------------------------------
# 留痕 / 统计 / 自描述
# --------------------------------------------------------------------------

# 生效条件：cg 与 rec 给出时，将 rec 序列化为 JSON 行追加到 cg.root 下 LOG_FILE；追加前若末字节不是换行（上一条被截在半行），先补一个换行再写本条，避免两条粘连成一行不可解析；OSError 被吞掉，返回 rec；
def log(cg, rec):
    """append-only 留痕：每条判定可审计。"""
    p = os.path.join(cg.root, LOG_FILE)
    try:
        line = json.dumps(rec, ensure_ascii=False) + "\n"
        # issue #225：写入端也防粘连——读取端容错只能让台账**可读**；残行若不补换行，
        # 本条会与残行拼成一行，两条留痕一起读不出来（丢的是**新**记录）。
        if ends_mid_line(p):
            line = "\n" + line
        with open(p, "a", encoding="utf-8") as f:
            f.write(line)
    except OSError:
        pass
    return rec


# 生效条件：cg 给出时，若 cg.root 下 LOG_FILE 可读，则容错逐行解析（空行/坏行/非对象行跳过，见 _records）；limit 真值时返回 out[-int(limit):][::-1]（int(limit)=0 时为全部倒序），limit 为假值（0/None/""）时返回 out[::-1]；路径不可用或 OSError 返回 []；
def history(cg, limit=100):
    """最近冲突判定留痕（倒序）。"""
    p = os.path.join(cg.root, LOG_FILE)
    if not os.path.exists(p):
        return []
    try:
        out = list(_records(p))
    except OSError:
        return []
    return out[-int(limit):][::-1] if limit else out[::-1]


# 生效条件：容错逐行解析计数（空行/坏行/非对象行跳过，见 _records；OSError 忽略、路径缺失则计数为 0），每行 total 加 1 并按 r.get("verdict")（缺键即 None 键）与 emotional.bias 为真值时的 b 累加；另返回 unparsable = 读不出来的行数（JSON 坏行 ＋ 非对象行），使诊断面不因坏行而假性健康；返回 {'records','unparsable','by_verdict','by_bias','max_scan','max_depth','min_gain'}，后三者取模块常量 MAX_SCAN/MAX_DEPTH/MIN_GAIN；
def summary(cg):
    """冲突面汇总（流式计数，供 health 审计）。"""
    p = os.path.join(cg.root, LOG_FILE)
    by_verdict, by_bias, total = {}, {}, 0
    stats = {"unparsable": 0}
    try:
        for r in _records(p, stats):
            total += 1
            v = r.get("verdict")
            by_verdict[v] = by_verdict.get(v, 0) + 1
            b = (r.get("emotional") or {}).get("bias")
            if b:
                by_bias[b] = by_bias.get(b, 0) + 1
    except OSError:
        pass
    return {"records": total, "unparsable": stats["unparsable"],
            "by_verdict": by_verdict, "by_bias": by_bias,
            "max_scan": MAX_SCAN, "max_depth": MAX_DEPTH,
            "min_gain": MIN_GAIN}


# 生效条件：无 required 形参，调用即返回引用 EMO_AVOID/EMO_APPROACH、VERDICTS、CLASH_HIGH/CLASH_LOW、SAME_COND_HIGH/SLOT_HIGH/CONCLUSION_SAME、MAX_DEPTH/MAX_NODES/MIN_GAIN 等模块级常量的自描述字典；
def catalog():
    """自描述：三级决策 + 四态 + 递归约束（供 MCP / 文档对照验证）。"""
    return {
        "levels": {
            "L0_emotion": {
                "theory": "情绪 = 信息差二阶变化 d²D/dt²（智能论 §十一）",
                "outputs": ["approaching", "stable", "avoiding"],
                "constraint": "独立通道，不参与信任/资格计算，只做流程调度",
                "thresholds": {"avoid": EMO_AVOID, "approach": EMO_APPROACH},
            },
            "L1_reflect": {
                "theory": "反题 = 预测与事实冲突（条件论七操作）",
                "checks": ["self_negation", "discipline", "condition_clash",
                           "same_condition_divergence"],
                "relations": {
                    "condition_clash": "跨侧比对（新正↔旧负 / 新负↔旧正）= 条件互斥（反题）",
                    "same_condition_divergence": "同侧比对（新正↔旧正 / 新负↔旧负）"
                                                 "＋ CCG 结论槽 = 同条件空间内的取值分歧（矛盾）",
                },
                "conclusion_normalization": "结论比对两侧同一套空白归一化"
                                            "（' '.join(x.split())）+ 去空白后逐字"
                                            "相同 ⇒ 必判重复（H8）",
                "verdicts": list(VERDICTS),
                "thresholds": {"high": CLASH_HIGH, "low": CLASH_LOW,
                               "same_condition": SAME_COND_HIGH, "slot": SLOT_HIGH,
                               "conclusion_same": CONCLUSION_SAME},
            },
            "L2_recursive_reflect": {
                "theory": "递归受深度/节点数/循环/信息增益门槛约束（智能论 :273）",
                "max_depth": MAX_DEPTH, "max_nodes": MAX_NODES,
                "min_gain": MIN_GAIN,
                "stop_reasons": ["resolved", "gain_below_threshold",
                                 "depth_exceeded", "node_budget",
                                 "frontier_exhausted"],
            },
        },
        "auto_flywheel": {
            "theory": "知识飞轮：误差 → 补条件 → 结构更新（智能论 :725）",
            # H11⑥（2026-09-30）：建单只限**真冲突**（REJECT）。DEFER 是
            # 「条件互斥/部分覆盖、待确认」，BLINDSPOT 是「检测前提不存在
            # （无节点可与比对）」——两者都不是「已判定冲突」，自动落 unresolved
            # 会把待确认项与无法比对项写成待办工单（且 BLINDSPOT 在空库上恒成立，
            # 即每写第一条内容就建一张工单）。可复核的现场：本轮
            # `consistency.check(..., non_applicable_conditions=["无"])` 实测
            # DEFER/strength=1.0 → 建单 unr_9af4803381，而同一内容不传该列表
            # 则 ACCEPT/0.0/无单——自动建单把「误判」直接固化成了待办。
            "triggers_on": list(FLYWHEEL_TRIGGERS),
        },
        "discipline_detection": {"tags": list(DISCIPLINE_TAGS),
                                 "id_prefixes": ["discipline_", "work_discipline"]},
        "honest_boundary": "条件级（结构化）冲突检测，非语义蕴含证明；无法比对时 BLINDSPOT",
    }