File size: 19,901 Bytes
216004d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""benchmark_three_state.py — **三态 epistemic state(好/坏/未知)的验收台**。

## 为什么需要这个文件

leo 的判断: 当前最大的缺口不是精度, 是 **epistemic state** ——
系统必须能区分"好、坏、**未知**", 而在此之前 `advq == 0` 被一律读成"不坏"。

那个 `0` 在两种完全不同的情况下出现:

    (a) 证据充分, 真的没有显著差异           -> 好
    (b) **奖励流为空, Φ 学不到东西**
        (harm 故障下整臂全灭时 `Q_adv ≡ 0.0000` 精确零)  -> **未知**

把 (b) 读成"不坏"就是**自欺**。这个文件的核心判据就是**直接测这个自欺**:
在 agent 正在整体失败的场景里, 旧报告会不会说"一切正常"?

## 三个固定场景(leo 阶段一)

    normal   无故障        —— 正常 option 应该被保留(判定为"好")
    inert    惰性故障      —— 看着效率差, 但**不应误杀**
    harm     有害故障      —— 真正有害, 应该被检出(判"坏", 或至少不是"好")

## 八项固定指标(leo 阶段一)

    ① harm 检测延迟   ② inert 误杀率   ③ 退休后恢复时间   ④ fallback 比例
    ⑤ 未知状态占比    ⑥ 主任务成功率/步数
    ⑦ 证据是否持续增长   ⑧ **自欺率**(失败时仍报"好"的比例)

## 两个臂: 三态本身是一条消融轴

    b2-nostate  冻结基线(GVF + advq + 遗忘 + 原谅)但 `rr_three_state=False`
    b2-3state   同上 + `rr_three_state=True`(未知 -> 一等状态)

这样"把未知提升为一等状态到底改变了什么"是可归因的。
"""
import sys
import json
import argparse
import numpy as np
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from hibs_lnn.rr_agent import RRSkillAgent
from hibs_lnn.skill_mdp import KeyDoorMDP, LEFT, RIGHT
from hibs_lnn.skill_agent import DEFAULT_REGIMES

FAULT_OPT = "rr[door]"

# 固定场景: (regime 序列, 故障, 从第几段起施加故障)
SCENARIOS = {
    "normal": dict(chain=[DEFAULT_REGIMES[i] for i in (0, 1, 2)],
                   fault=None, fault_at=99),
    "inert":  dict(chain=[DEFAULT_REGIMES[i % 3] for i in range(6)],
                   fault="inert", fault_at=3),
    "harm":   dict(chain=[DEFAULT_REGIMES[i % 3] for i in range(6)],
                   fault="harm", fault_at=3),
    # ★★ "不稳定" 故障 —— 与 `harm`("稳定但错误")形成对照。
    #    leo 点名的否证实验就在这里: 如果 A 类只对 `chaos` 有反应、
    #    对 `harm` 无反应, 那它学到的是**可预测性**, 不是**任务价值**,
    #    因此只能当辅助/风险通道, 不能单独驱动退休或选择。
    "chaos":  dict(chain=[DEFAULT_REGIMES[i % 3] for i in range(6)],
                   fault="chaos", fault_at=3),
}


class _RandPolicy:
    """`chaos` 故障: **每步随机出动作** —— 动力学不可预测。

    与 `harm`(把 π_o 反向后仍然完全确定)正好构成一对:
      · `harm`  = 稳定 + 错误   -> 可预测性高, 任务价值负
      · `chaos` = 不稳定 + 无向 -> 可预测性低, 任务价值也是负
    两者对比可以把"熟悉度/可预测性"和"任务价值"**分离**开 —— 这是 A 类
    唯一能证明自己是值信号还是辅助信号的办法。
    """

    def __init__(self, seed=0):
        self.rng = np.random.RandomState(seed)

    def __getitem__(self, s):
        return int(self.rng.randint(2))

    def __len__(self):
        return 64

# ── 冻结基线(leo 契约): GVF + advq + 遗忘 + 原谅 ──────────────────────
FROZEN = dict(rr_select_rule="calibrated", rr_pot="gvf", rr_retire_rule="advq",
              rr_score="advq", rr_forget_hl=20, rr_forgive_p=0.10)

ARMS = {
    "uniform":    dict(rr_select_rule="uniform"),
    "b2-nostate": dict(FROZEN, rr_three_state=False),
    "b2-3state":  dict(FROZEN, rr_three_state=True),
    # ★ 阶段四 A: 三候选稠密动力学证据。其余**全部**沿用冻结契约,
    #   所以臂间差异只能归因到 cumulant 本身(leo 的归因纪律)。
    "a1": dict(FROZEN, rr_three_state=True, rr_dyn="A1"),   # 转移不确定性
    "a2": dict(FROZEN, rr_three_state=True, rr_dyn="A2"),   # 转移预测误差
    "a3": dict(FROZEN, rr_three_state=True, rr_dyn="A3"),   # 已知稳定区域
}


def apply_fault(ag, kind):
    """把故障加到目标 option 上。故障**只在行为上**, 不改台账键。"""
    st = next((s for s in ag.rr_lib_obj if getattr(s, "name", None) == FAULT_OPT), None)
    if st is None:
        return False
    if kind == "inert":                   # 看着坏: 达不成子目标...
        ag._rr_cap[FAULT_OPT] = 1         # ...但那一步仍是 π_o 的最优步
    elif kind == "harm":                  # 真的坏: 主动朝反方向走
        #   ★ 这个故障的性质是 **"稳定 + 错误"**: 转移完全确定、极易预测,
        #     但任务价值是负的。它同时是 A 类的**否证实验**(见判据⑧)。
        p = np.asarray(st.policy).copy()
        p2 = p.copy()
        p2[p == LEFT] = RIGHT
        p2[p == RIGHT] = LEFT
        st.policy = p2
    elif kind == "chaos":                 # 不稳定: 每步随机动作
        #   转移**不可预测**, 与 `harm` 的可预测性形成对照。
        st.policy = _RandPolicy(seed=12345)
        ag._rr_cap[FAULT_OPT] = 12        # 不让它无限跑
    else:
        raise ValueError(f"unknown fault: {kind}")
    return True


def run_arm(kw, scen, episodes_per=300, seed=0, thresh=0.8, window=20):
    sc = SCENARIOS[scen]
    mdp = KeyDoorMDP(n_pos=8, horizon=30, **sc["chain"][0])
    ag = RRSkillAgent(mdp, mode="rr", seed=seed, rr_lib="rr", opt_prob=1.0, **kw)
    segs = []
    for ci, reg in enumerate(sc["chain"]):
        mdp.set_regime(reg["key_pos"], reg["door_pos"], reg["goal_pos"])
        ag._rr_ensure()
        faulted = (sc["fault"] is not None and ci >= sc["fault_at"])
        if faulted:
            apply_fault(ag, sc["fault"])
        n0 = ag.rr_outcome.get(FAULT_OPT, {}).get("n", 0)
        broken_at = None
        succ, steps_ok = [], []
        for _ in range(episodes_per):
            ok, stp = ag.run_episode()
            succ.append(1.0 if ok else 0.0)
            if ok:
                steps_ok.append(stp)
            if faulted and broken_at is None and FAULT_OPT in ag.rr_broken():
                broken_at = ag.rr_outcome[FAULT_OPT]["n"] - n0
        s = np.array(succ)
        t_adapt = episodes_per
        for e in range(window - 1, episodes_per):
            if float(s[e - window + 1:e + 1].mean()) >= thresh:
                t_adapt = e + 1
                break
        cs = ag.rr_calib_stats()
        o = cs["per_option"].get(FAULT_OPT, {})
        others = {k: v for k, v in cs["per_option"].items() if k != FAULT_OPT}
        segs.append({
            "seg": ci, "tc": reg.get("goal_pos"), "faulted": bool(faulted),
            "t_adapt": int(t_adapt),
            "succ": float(s[-window:].mean()),
            "steps_go": float(np.mean(steps_ok)) if steps_ok else float("nan"),
            # ① 检测: 目标是坏的
            "detect": broken_at,
            "bad_state": o.get("state"),
            # ② 误杀: 非目标的 option 被判坏
            "false_kill": sum(1 for v in others.values() if v["state"] == "bad"),
            # ③ 恢复: 目标从坏恢复过几次
            "recovered": o.get("recovered_n", 0),
            "retired": o.get("retired_n", 0),
            # ④ fallback = 退回基元层的**选择尝试占比**。分母必须是 `sel_attempts`
            #   (尝试总数), 不能用 `selections`(只在真的选中时 +1) —— 否则
            #   比值会 > 1(实测 6.827), 指标失去意义。
            "fallback": ag.rr_stats.get("starved_sel", 0)
            / max(1, ag.rr_stats.get("sel_attempts", 1)),
            # ⑤ 三态占比
            "states": cs["states"],
            "starved": cs["starved"],
            "since_rew": cs["since_rew"],
            # ⑦ 证据增长
            "evidence": float(sum(v["advq_n"] for v in cs["per_option"].values())),
            # ★ A 类通道: 转移数 / 基线压力 / 每 option 的证据量与风险标记
            "dyn": (ag.rr_dyn_stats() if hasattr(ag, "rr_dyn_stats") else None),
            "no_base": o.get("no_base", 0),
            "advq": o.get("advq", 0.0),
            "advq_lo": o.get("advq_lo", 0.0),
            "advq_n": o.get("advq_n", 0.0),
            "usage": o.get("n", 0) / max(1, sum(v["n"] for v in cs["per_option"].values())),
        })
    return segs, ag


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--episodes-per", type=int, default=300)
    ap.add_argument("--seeds", type=int, default=1)
    ap.add_argument("--scenarios", default="normal,inert,harm")
    ap.add_argument("--arms", default="uniform,b2-nostate,b2-3state")
    ap.add_argument("--out", default=None)
    a = ap.parse_args()
    scen_names = a.scenarios.split(",")
    arms = {k: v for k, v in ARMS.items() if k in a.arms.split(",")}

    out, agents = {}, {}
    for sc in scen_names:
        for name, kw in arms.items():
            runs = [run_arm(kw, sc, a.episodes_per, sd) for sd in range(a.seeds)]
            out[(sc, name)] = [r[0] for r in runs]
            agents[(sc, name)] = runs[-1][1]

    # ── 打印 ────────────────────────────────────────────────────────
    for sc in scen_names:
        print("\n" + "=" * 108)
        print(f"场景 {sc}   故障={SCENARIOS[sc]['fault']}   从第 {SCENARIOS[sc]['fault_at']} 段起")
        print("=" * 108)
        for name in arms:
            runs = out[(sc, name)]
            print(f"\n── {name} " + "─" * max(0, 96 - len(name)))
            print(f"   {'段':>3}{'故障':>6}{'T_adapt':>9}{'步数':>8}{'成功率':>8}"
                  f"{'使用率':>8}{'advq':>9}{'下界':>9}{'advq_n':>8}"
                  f"{'判定':>8}{'好/坏/未知':>14}{'检测':>7}{'误杀':>6}")
            for si in range(len(SCENARIOS[sc]["chain"])):
                g = [r[si] for r in runs]
                m = lambda f: float(np.nanmean([x[f] for x in g]))
                dv = [x["detect"] for x in g if x["detect"] is not None]
                dl = (f"{np.mean(dv):.0f}" if dv
                      else ("未检测" if g[0]["faulted"] else "—"))
                st = m_states = g[0]["states"]
                st_s = f"{m_states['good']}/{m_states['bad']}/{m_states['unknown']}"
                print(f"   {si:>3}{'是' if g[0]['faulted'] else '否':>6}"
                      f"{m('t_adapt'):>9.1f}{m('steps_go'):>8.2f}{m('succ'):>8.3f}"
                      f"{m('usage'):>8.3f}{m('advq'):>9.4f}{m('advq_lo'):>9.4f}"
                      f"{m('advq_n'):>8.1f}{str(g[0]['bad_state']):>8}{st_s:>14}"
                      f"{dl:>7}{m('false_kill'):>6.0f}")

    # ── 事前判据 ────────────────────────────────────────────────────
    print("\n" + "=" * 108)
    print("事前判据")
    print("=" * 108)
    ok = []
    # ★ `info` = **不计入通过率**的观察项。用于"判据本身还不成立/已被取代"的条目:
    #   留在 `ok` 里会让它永远红, 从而淹没真正的回归(退出码失去分辨力)。
    #   一条判据只有在**它声称验的那件事可以被验**时才有资格当门。
    info = []

    def segs_(sc, nm):
        return out[(sc, nm)][0]

    def last_faulted(sc):
        ch = SCENARIOS[sc]["chain"]
        return [i for i in range(len(ch)) if i >= SCENARIOS[sc]["fault_at"]]

    # ★★ ① 自欺门: 任何"奖励流为空"的段, 都**不得报"好"**
    #    这是 leo 点的核心缺口的直接检验: `advq == 0` 在"证据充分无差异"和
    #    "奖励流为空根本没数据"两种情况下都出现; 前者可以报"好", 后者不行。
    #    注意这个判据对**所有臂**成立 —— 它是报告层的不变量, 与决策规则无关。
    lies = []
    for sc in scen_names:
        for nm in arms:
            for si, x in enumerate(segs_(sc, nm)):
                if x["starved"] and x["states"]["good"] > 0:
                    lies.append((sc, nm, si, x["states"], x["since_rew"]))
    ok.append(("① 自欺门: 奖励流为空时从不报'好'", len(lies) == 0,
               f"违约 {len(lies)} 处" + (f" 例: {lies[0]}" if lies else "")))

    # ★★ ② 决策有效性: 三态开着 -> agent 会因"坏/未知"停止使用有害 option 而**恢复**
    #    这是在测"把未知提升为一等状态"到底改变了什么: 不是报告更好看,
    #    是行为上能自救。
    if "harm" in scen_names and {"b2-nostate", "b2-3state"} <= set(arms):
        nf = [segs_("harm", "b2-nostate")[i] for i in last_faulted("harm")]
        tf = [segs_("harm", "b2-3state")[i] for i in last_faulted("harm")]
        su_n = float(np.mean([x["succ"] for x in nf]))
        su_t = float(np.mean([x["succ"] for x in tf]))
        ok.append(("② 三态使 agent 在失败场景下恢复 (成功率 3state 显著 > nostate)",
                   su_t > su_n + 0.2, f"{su_n:.3f} -> {su_t:.3f}"))

    # ③ 未知不得触发退休 / 不得增加误杀
    #    ★ 守卫必须有: 臂子集是常规用法(--arms a1,a2,a3), 缺守卫会直接
    #      KeyError 崩在**评测阶段** —— 数据全跑完了才崩, 最浪费的一种。
    if "harm" in scen_names and {"b2-nostate", "b2-3state"} <= set(arms):
        print(f"     harm 末段 误杀(非目标 option 被判坏): "
              f"nostate {segs_('harm','b2-nostate')[-1]['false_kill']} / "
              f"3state {segs_('harm','b2-3state')[-1]['false_kill']}")
        ok.append(("③ 三态不增加误杀",
                   segs_("harm", "b2-3state")[-1]["false_kill"]
                   <= segs_("harm", "b2-nostate")[-1]["false_kill"]))

    # ④ inert 不应被系统性误杀
    if "inert" in scen_names:
        for nm in ("b2-nostate", "b2-3state"):
            if nm in arms:
                fk = sum(segs_("inert", nm)[i]["false_kill"] for i in last_faulted("inert"))
                print(f"     inert 故障段累计误杀 {nm}: {fk}")
        ok.append(("④ inert 场景下三态臂不误杀",
                   all(segs_("inert", "b2-3state")[i]["false_kill"] == 0
                       for i in last_faulted("inert"))))

    # ⑤ 正常场景应判"好"(三态不能退化成一律未知)
    if "normal" in scen_names:
        c = segs_("normal", "b2-3state")[-1]
        print(f"     normal 末段: 三态 {c['states']}  (starved={c['starved']})")
        ok.append(("⑤ normal 场景下目标 option 最终判'好'", c["states"]["good"] >= 1))

    # ⑥ 证据持续增长
    for sc in scen_names:
        if "b2-3state" not in arms:
            continue
        ev = [x["evidence"] for x in segs_(sc, "b2-3state")]
        ok.append((f"⑥ [{sc}] 证据持续增长 (advq_n 单调)",
                   all(ev[i + 1] >= ev[i] - 1e-9 for i in range(len(ev) - 1))))

    # ★★ ⑨ **反熟悉度否证实验**(leo 点名的最重要一条; 编号接在既有 ⑧ 之后)
    #    对两个镜像故障各问一个问题:
    #      chaos(不稳定): A 类**应该**察觉到 -> 证明它测的是"可预测性"
    #      harm (稳定+错误): A 类**应该**察觉不到 -> 证明它测的**不是**
    #                        "任务价值", 因此只能当辅助/风险通道
    #    如果 A 在 harm 上也"察觉到了", 那是更可疑的情况(需另查来源);
    #    如果 A 在 chaos 上毫无反应, 那它连"可预测性"都没测到 -> 直接淘汰。
    if "chaos" in scen_names:
        def dyn_flag(sc, nm):
            """故障段里 A 类触发风险的 (命中, 总数)。"""
            hit = tot = 0
            for x in segs_(sc, nm):
                dy = x.get("dyn")
                if not dy or not x["faulted"]:
                    continue
                for v in dy["per_option"].values():
                    tot += 1
                    hit += int(v["risk"])
            return hit, tot
        for nm in ("a1", "a2", "a3"):
            if nm not in arms:
                continue
            hc, tc = dyn_flag("chaos", nm)
            hh, th = dyn_flag("harm", nm)
            # ★★ **本条已从"门"降级为"观察项"(INFO)** —— 理由是它**不可满足**:
            #    本台跑在**确定性** KeyDoorMDP 上, 实测所有 n>=2 的 (s,a) 后继分布
            #    最大熵**逐位 0.000000** ⇒ H(T|S,A) ≡ 0 ⇒ 转移预测误差 / 不确定性 /
            #    稳定区域 **在数学上坍缩成同一个量 = 新颖度计数**。
            #    于是 A 在 chaos 与 harm 上必然给出**同一签名** —— 这不是
            #    "A 类不行", 是**本环境没有能力分辨**。
            #    留它在 `ok` 里会让整台永远 3 条红, 退出码失去分辨力
            #    (真正的回归会被淹没)。
            #    新定义(T_shift/T_detect/T_recover/FP/FN/AUC)与可辨识环境见
            #    `tests/benchmark_a_prime.py` + `docs/a_prime_decision.md`。
            info.append((f"⑨ [{nm}] (已被取代, 见 benchmark_a_prime.py) "
                         f"本台不可满足: 确定性环境 H(T|S,A)≡0, A 类 ≡ 新颖度",
                         f"chaos {hc}/{tc}   harm {hh}/{th}"))

    # ⑦ 主任务不劣于安全基线 —— **只在 uniform 真正能跑的场景比**
    #    (harm 下 uniform 整体失败 steps_go=nan, 那时比较没有意义;
    #     失败场景由判据 ② 用成功率来测)
    for sc in scen_names:
        if "uniform" not in arms or "b2-3state" not in arms:
            continue
        fu = np.nanmean([x["steps_go"] for x in segs_(sc, "uniform")])
        fs = np.nanmean([x["steps_go"] for x in segs_(sc, "b2-3state")])
        # ★ 安全基线的可用性必须**只在故障段上**判: harm 下 uniform 在故障段
        #   成功率 0.000 / 步数 nan(整体失败), 那时"比步数"没有意义 ——
        #   用全段均值会把健康段的成功混进来, 误以为基线可用。
        uf = [x for x in segs_(sc, "uniform") if x["faulted"]]
        us_f = np.mean([x["succ"] for x in uf]) if uf else 1.0
        if np.isnan(fu) or np.isnan(fs) or us_f < 0.5:
            ok.append((f"⑦ [{sc}] 主任务不劣于 uniform", True,
                       f"uniform 在故障段基线不可用 (成功率 {us_f:.3f}), 由判据②覆盖"))
        else:
            ok.append((f"⑦ [{sc}] 主任务步数不劣于 uniform (增幅 < 15%)",
                       fs <= fu * 1.15, f"{fu:.2f} -> {fs:.2f}"))

    n_pass = 0
    for item in ok:
        nm, v = item[0], item[1]
        extra = item[2] if len(item) > 2 else None
        n_pass += int(bool(v))
        print(f"  {'PASS' if v else 'FAIL'}  {nm}" + (f"   [{extra}]" if extra else ""))
    for item in info:                       # ★ 不计入通过率, 不影响退出码
        nm = item[0]
        extra = item[1] if len(item) > 1 else None
        print(f"  INFO  {nm}" + (f"   [{extra}]" if extra else ""))
    print(f"\n  {n_pass}/{len(ok)} 通过" +
          (f"   (+{len(info)} 条 INFO, 不计入)" if info else ""))

    if a.out:
        Path(a.out).parent.mkdir(parents=True, exist_ok=True)
        ser = {f"{sc}|{nm}": runs for (sc, nm), runs in out.items()}
        Path(a.out).write_text(json.dumps(ser, indent=2, default=str, ensure_ascii=False))
        print(f"原始数据: {a.out}")
    return 0 if n_pass == len(ok) else 1


if __name__ == "__main__":
    sys.exit(main())