@furongjun1999/dsh-memory 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +49 -18
  2. package/docs/README.md +1 -0
  3. package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +223 -0
  4. package/docs/eval/cons200_/345/206/262/347/252/201/346/243/200/346/265/213/351/200/211/351/235/242_/345/256/236/346/226/275/350/256/260/345/275/225_v1.1.md +340 -0
  5. package/docs/eval/issue50_/345/205/203/346/225/260/346/215/256/351/200/217/344/274/240/344/270/216/345/205/234/345/272/225_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +437 -0
  6. package/docs/eval/issue50_/345/215/212/351/207/215/345/244/215/345/276/205/345/256/232/345/244/215/346/240/270_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +488 -0
  7. package/docs/eval/issue50_/345/276/205/345/256/232/345/244/215/346/240/270/345/205/245/351/230/237_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +428 -0
  8. package/docs/eval/issue50_/350/257/273/351/235/242/344/277/235/346/212/244/345/217/252/350/256/244/346/230/276/345/274/217/346/235/245/346/272/220_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +271 -0
  9. package/docs/eval/issue50_/351/207/215/350/246/201/345/272/246/345/220/214/346/272/220/344/270/216/344/277/235/346/212/244/350/257/255/344/271/211_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +306 -0
  10. package/docs/eval/issue51_/344/270/200/351/224/256/345/256/211/350/243/205/345/244/261/350/264/245_/345/275/222/345/261/236/345/210/244/345/256/232_v1.0.md +58 -0
  11. package/docs/eval/issue52_/346/235/241/344/273/266/345/205/210/350/241/214/344/270/216/346/210/252/346/226/255/345/217/257/350/247/202/346/265/213_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +360 -0
  12. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/241/346/241/243/344/275/215/345/215/225/344/270/200/345/205/245/345/217/243_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +651 -0
  13. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/242/345/217/230/346/233/264/345/215/225/344/270/216/345/233/236/346/273/232/345/216/237/350/257/255_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +439 -0
  14. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/243/345/207/206/345/205/245/350/257/273/346/225/260_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1337 -0
  15. package/docs/eval//344/270/211/346/241/243/350/207/252/346/262/273_/346/255/245/351/252/244/342/221/244/346/224/266/345/256/230/344/270/216/345/205/250/351/223/276/351/252/214/346/224/266_/350/220/275/347/240/201/350/256/260/345/275/225_v1.0.md +1817 -0
  16. package/docs/eval//345/207/272/350/264/247/351/235/242/345/206/222/347/203/237_/350/277/233/350/264/247/351/227/250/347/246/201_v1.0.md +460 -0
  17. package/docs/eval//345/217/221/345/270/20308_/350/207/252/350/277/255/344/273/243/344/270/216/347/235/241/347/234/240_/345/233/276/346/243/200/347/264/242/350/267/257/344/270/216/346/235/203/351/207/215_v1.0.md +97 -0
  18. package/docs/eval//345/217/221/345/270/20309_/346/243/200/347/264/242/351/235/242/344/270/211/346/211/271/346/224/266/345/217/243_v1.0.md +66 -0
  19. package/docs/eval//345/275/222/344/270/200/345/261/202/347/274/272/347/234/201/347/277/273/345/205/263_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +458 -0
  20. package/docs/eval//347/254/2543/345/261/202stg/347/273/223/346/236/204/347/264/242/345/274/225_/345/256/236/346/226/275/350/256/260/345/275/225_v1.0.md +250 -0
  21. package/docs/hive//346/243/200/347/264/242/347/256/227/346/263/225/345/217/243/345/276/204/345/257/271/347/205/247_v0.1.md +136 -11
  22. package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +32 -2
  23. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +88 -0
  24. package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +40 -40
  25. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +47 -11
  26. package/docs/mdcg//347/235/241/347/234/240/345/221/250/346/234/237_/350/277/220/347/273/264/345/211/215/346/217/220/344/270/216/347/273/264/346/212/244/346/214/207/345/215/227_v1.0.md +183 -0
  27. package/docs/plans/stg/346/235/241/344/273/266/345/214/226/344/270/216/347/273/223/346/236/204/347/264/242/345/274/225_/350/256/276/350/256/241_v0.1.md +124 -0
  28. package/docs/plans//347/235/241/347/234/240/344/270/216/350/207/252/350/277/255/344/273/243_/345/212/237/350/203/275/344/274/230/345/214/226/350/256/276/350/256/241_v0.4.md +547 -0
  29. package/docs/plans//350/256/260/345/277/206/350/207/252/345/244/204/347/220/206/344/270/211/346/241/243/350/207/252/346/262/273_/350/256/276/350/256/241_v0.2.md +207 -0
  30. package/md_cg/admission.py +718 -0
  31. package/md_cg/autonomy_modes.py +642 -0
  32. package/md_cg/bench_e2e_locomo_qa.py +11 -4
  33. package/md_cg/chain.py +47 -0
  34. package/md_cg/consistency.py +133 -8
  35. package/md_cg/forgetting.py +38 -6
  36. package/md_cg/freshness.py +527 -0
  37. package/md_cg/generation.py +409 -0
  38. package/md_cg/hotcache.py +4 -1
  39. package/md_cg/lifecycle.py +30 -2
  40. package/md_cg/mcp_server.py +191 -26
  41. package/md_cg/mdcg.py +594 -39
  42. package/md_cg/mdcos.py +869 -38
  43. package/md_cg/nodefile.py +74 -1
  44. package/md_cg/protect.py +79 -4
  45. package/md_cg/provenance.py +1 -1
  46. package/md_cg/review_cli.py +31 -2
  47. package/md_cg/rollback.py +411 -0
  48. package/md_cg/rollback_cli.py +104 -0
  49. package/md_cg/semantic/canonical.py +22 -0
  50. package/md_cg/semantic/unify.py +73 -22
  51. package/md_cg/semantic/unify_fixture.json +25 -0
  52. package/md_cg/sleep.py +1297 -0
  53. package/md_cg/stg.py +400 -50
  54. package/md_cg/stgidx.py +281 -0
  55. package/md_cg/sustain.py +146 -9
  56. package/md_cg/test_auto_defaults.py +424 -0
  57. package/md_cg/test_autonomy_admission.py +1098 -0
  58. package/md_cg/test_autonomy_modes.py +1972 -0
  59. package/md_cg/test_b1_auto_id_multiproc.py +7 -0
  60. package/md_cg/test_b1b2_write_face.py +7 -0
  61. package/md_cg/test_b3_merge_keeps_content.py +7 -0
  62. package/md_cg/test_boundary_hit.py +410 -0
  63. package/md_cg/test_cons200_scan_selection.py +791 -0
  64. package/md_cg/test_en_pipeline.py +22 -13
  65. package/md_cg/test_generation_guard.py +352 -0
  66. package/md_cg/test_h4_sustain_snapshot.py +14 -4
  67. package/md_cg/test_i50a_half_dup_defer.py +408 -0
  68. package/md_cg/test_i50b_defer_to_review_queue.py +545 -0
  69. package/md_cg/test_i50c_meta_passthrough.py +608 -0
  70. package/md_cg/test_i50d_importance_source.py +531 -0
  71. package/md_cg/test_i50e_readside_protection.py +656 -0
  72. package/md_cg/test_issue39_utf8_stdio.py +9 -1
  73. package/md_cg/test_issue52_scan_condition_first.py +677 -0
  74. package/md_cg/test_linkref.py +7 -0
  75. package/md_cg/test_mode_parity.py +1393 -0
  76. package/md_cg/test_mutation_rollback.py +805 -0
  77. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +1 -1
  78. package/md_cg/test_n212_n213_n224_generation_gates.py +14 -8
  79. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +7 -0
  80. package/md_cg/test_n230_dirty_replay.py +375 -0
  81. package/md_cg/test_p2_mcp.py +8 -0
  82. package/md_cg/test_p2_six_elements.py +463 -0
  83. package/md_cg/test_p3_legacy_closure.py +433 -0
  84. package/md_cg/test_p4_freshness.py +680 -0
  85. package/md_cg/test_p8_subgraph_chain.py +14 -1
  86. package/md_cg/test_p9_forget_protect.py +7 -0
  87. package/md_cg/test_p9c_dedup_hints.py +7 -0
  88. package/md_cg/test_policy_required_ccg.py +5 -1
  89. package/md_cg/test_protocol.py +7 -0
  90. package/md_cg/test_rank_parity_score_mode.py +6 -0
  91. package/md_cg/test_semantic_canonical.py +5 -3
  92. package/md_cg/test_sleep.py +611 -0
  93. package/md_cg/test_sleep_p1.py +784 -0
  94. package/md_cg/test_stgidx_index_parity.py +1020 -0
  95. package/md_cg/test_time_core_lint.py +968 -0
  96. package/md_cg/test_unify_default_off.py +701 -0
  97. package/md_cg/test_unify_scope.py +182 -0
  98. package/md_cg/test_writelimit.py +36 -9
  99. package/md_cg/test_writepipe.py +5 -2
  100. package/md_cg/weights.py +15 -1
  101. package/md_cg/whitebox_kb/aeis_core/time_core.py +8 -0
  102. package/md_cg/writepipe.py +101 -2
  103. package/package.json +3 -2
  104. package/skills/plugin.json +1 -1
  105. package/utf8_boot.py +237 -0
@@ -0,0 +1,608 @@
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · issue50-c 守卫:DEFER→accept 的元数据完整性(F1)+ node_id=None 捩点(F2)
3
+
4
+ 缺陷(编排侧复核实测,非推断)
5
+ ------------------------------
6
+ F1(扩面修)`md_cg/mdcos.py::remember_gated` 的 DEFER 出口接线入队时**只传
7
+ content/layer/sensitivity**(issue50-b 接线),调用方声明的其余 meta 全丢:
8
+ `tags` / `condition_space` / `verification_basis` / `non_applicable_conditions` /
9
+ `derived_from` / `relation`(以及 `role`)。accept 只能从队列记录取值,于是
10
+ **同一声明**下「DEFER→经队列 accept 落盘」的节点比「直接 ACCEPT」少掉这些键
11
+ (实测 tags 为 [] 而对面有值、derived_from 缺失、bucket_zh 因 tags 空而不生成)
12
+ ——同一份声明的两条落地路径**元数据不等价**。
13
+
14
+ F2(捩点收口)`remember_gated(None, 半重复内容, …)` 现行会崩:新 DEFER 分支里
15
+ `propose` 造 pid 时拼接 `node_id + str(time.time())` ⇒
16
+ `TypeError: unsupported operand type(s) for +: 'NoneType' and 'str'`。生产入口经
17
+ `mint_auto_id` 兜底故不可达,但「静默 → 抛异常」是新引入的面。
18
+
19
+ 本批修复(契约冻结)
20
+ --------------------
21
+ F1:入队时把本闸收到的 meta 一并落进队列记录——`**kw` 交给 `self.propose`
22
+ (其形参 `tags`/`condition_space`/`sensitivity`/`verify` 落 rec 专属槽,其余键落
23
+ `extra`),口径与直接 ACCEPT 的 `add(**kw)` **同源**;accept 分支既有的
24
+ `tags=` / `condition_space=` / `**extra` 映射即把它写回节点 fm。不新增队列字段,
25
+ 不改队列裁决动词,`review_decide` **零行为改动**(其 accept 映射早已在位)。
26
+ F2:`node_id` 非字符串时按仓内既有形态兜底成**内容派生 id**
27
+ (`forgetting._prefeed_id` 同款,`pre_<sha1[:12]>`),不崩且非静默(返回体
28
+ `proposed_id_fallback` 如实标注;**不进** forgetting 留痕行——该行形状由
29
+ issue50-b 冻结)。
30
+
31
+ 两路对拍口径(本守卫的 A/B 组)
32
+ -------------------------------
33
+ 同一声明(同一 `tags`/`condition_space`/`verification_basis`/
34
+ `non_applicable_conditions`/`derived_from`/`relation`/`role`/`importance`)跑两个
35
+ **隔离临时根**(同名节点 `n`):一路内容全新 ⇒ 直接 ACCEPT;另一路内容与基线半
36
+ 重复(dup≈0.73)⇒ DEFER ⇒ 入队 ⇒ `review_decide(accept)`。逐键对拍两个节点的
37
+ frontmatter。`importance` 用显式 `importance=`(而非 `importance_hint=`)传入,
38
+ 使两条路径的 fm.importance 同源——「裁决用与落盘记的重要度不同源」已由
39
+ issue50-d 收口(remember_gated ACCEPT 分支落盘=裁决值),显式 `importance=`
40
+ 的 hint 面两口径等价,本守卫的对拍口径不受影响。
41
+
42
+ 白名单(B 组逐键读数的允许差异,每条附理由)
43
+ --------------------------------------------
44
+ · `defer_reason` —— issue50-b 引入的「为何待定」文案,**仅 DEFER 路有**
45
+ (契约已裁定接受,本批保持现状)。
46
+ · `reviewer` —— accept 路径的裁决归属留痕(mdcos.py:2565-2567)。硬边界
47
+ ②「不许削弱 review_decide 既有职责」⇒ 不可为「对齐」而删;直接 ACCEPT 路
48
+ 不经裁决,本就没有。
49
+ · `created_at` —— 写入时刻时间戳(时间轴锚点):直接路写入即落盘、DEFER 路
50
+ 裁决 accept 时才落盘,两次时刻必然不同;**非调用方声明的 meta**。
51
+ · `condition_space` —— 仅其**派生**键 `time_window`(= created_at 起算,
52
+ add :2225)随之变;其余键必须逐位相等(B3 单列)。
53
+ · `importance_source` —— issue50-d(2026-10-02)新增的**裁决面衍生键**
54
+ ("hint"|"heuristic",标注落盘重要度来自显式声明还是闸门启发式),**仅
55
+ ACCEPT 直接路有**:DEFER 路经队列 accept 落盘,落盘时不存在本闸的 ACCEPT
56
+ 裁决,该键无从产生(issue50-d 裁决①只覆盖 remember_gated 的 ACCEPT 分支)。
57
+ 它不是调用方声明的 meta,不在 F1「声明不丢」的判据面内(B2 已排除)。
58
+ 其余三个白名单项之外的任何差异必须为 0(B1 逐行读数,不是结论)。
59
+
60
+ 断言分组(全部在**隔离临时根**的合成库上真跑,绝不触在役数据根)
61
+ A 修复面:DEFER→accept 节点的声明 meta 与直接 ACCEPT 路**逐位相等**(六键
62
+ 点名 + role/derived_relation/bucket_zh 佐证;并证「两路都有」而非「两路都缺」)
63
+ B 白名单外差异为 0(逐键对拍表 + 白名单恰 4 条 + 缺键为 0 + cs 非 time_window 键相等)
64
+ C `node_id=None` 不抛异常且行为可辨(DEFER / 入队 / 内容派生 id / 如实标注 / accept 闭环)
65
+ D 裁决既有职责不削弱(幂等对账、自验违例、reject/noop 不落盘、merge 并集、留痕)
66
+
67
+ 定点变异自证(--mutate)
68
+ ------------------------
69
+ 每一处判据都配一个定点变异把它打红,且**恰好**命中预期红项数(`_MUTATIONS`
70
+ 的 expected 列;实测计数不符即判 FAIL——与 test_neg_condition_hits.py /
71
+ test_i50a / test_i50b 同口径)。锚点必须**逐字**出现在当前盘实现的源码里;漂移
72
+ 即 ANCHOR-MISS 并 **exit 2**(fail-closed,默认模式同样先自检)。
73
+ **不以 git HEAD 为基线源**——基线绑提交即失效(本仓已有两次教训);变异一律
74
+ 作用在**当前盘的实现**上。
75
+
76
+ 运行:python -X utf8 -m md_cg.test_i50c_meta_passthrough
77
+ python -X utf8 -m md_cg.test_i50c_meta_passthrough --mutate
78
+ """
79
+ from __future__ import annotations
80
+
81
+ import contextlib
82
+ import inspect
83
+ import io
84
+ import json
85
+ import os
86
+ import shutil
87
+ import sys
88
+ import tempfile
89
+
90
+ from . import forgetting
91
+ from .mdcos import MdCGOS, MdCGSecure
92
+ from .security import Principal
93
+
94
+ _PASS = []
95
+ _FAIL = []
96
+
97
+
98
+ def ok(cond, msg, extra=""):
99
+ (_PASS if cond else _FAIL).append(msg)
100
+ print((" PASS " if cond else " FAIL ") + msg
101
+ + ((" ← " + str(extra)) if (extra and not cond) else ""))
102
+
103
+
104
+ # ---------------------------------------------------------------- 夹具构造
105
+ # 与 test_i50a / test_i50b 同一套夹具口径:600 个互异汉字作基线正文,另 600 个
106
+ # 互异汉字作填充;前 k 字的覆盖比例即重复度(k=436 → dup≈0.73,落在
107
+ # DUP_DROP=0.60 与 DUP_MERGE=0.85 之间 ⇒ 分支④ DEFER)。
108
+ BASE = "".join(chr(0x4e00 + i) for i in range(600))
109
+ FILL = "".join(chr(0x8000 + i) for i in range(600))
110
+ NOVEL = "".join(chr(0x9000 + i) for i in range(600))
111
+ T_BASE, T_NEW = "基线节点标题", "新写入标题"
112
+ K_HALF = 436
113
+ NID = "n" # 两个隔离根用**同一个**节点 id,使 fm.id 不成为差异源
114
+
115
+ # 同一声明(两路逐字相同)。`relation` 取非缺省值 refined_from——缺省值恰等于
116
+ # provenance.DEFAULT_RELATION,无法区分「relation 生效」与「走了缺省」。
117
+ META = dict(tags=["标签甲", "标签乙"],
118
+ condition_space={"observation_position": "pos-x"},
119
+ verification_basis="test",
120
+ non_applicable_conditions=["不适用甲", "不适用乙"],
121
+ derived_from=["base"],
122
+ relation="refined_from",
123
+ role="user",
124
+ importance=0.5)
125
+
126
+ _ABSENT = object()
127
+
128
+
129
+ # 生效条件:title 与 body 给定时按本仓 CCG 六要素模板拼出节点正文(模板行两侧
130
+ # 一致,故不成为重复度差量来源);无输入校验,参数缺失即抛 TypeError。
131
+ def _doc(title, body):
132
+ return ("# 功能名:%s\n# 生效条件:任意情境\n# 子功能:验收\n"
133
+ "# 执行:直接调用\n# 验证方式:test\n# 不适用条件:无\n%s\n"
134
+ % (title, body))
135
+
136
+
137
+ # 生效条件:k 为整数下标;返回「前 k 字取自 BASE、其余取自 FILL」的新正文。
138
+ def _mixed(k):
139
+ return BASE[:k] + FILL[:len(BASE) - k]
140
+
141
+
142
+ # 生效条件:sess 为真值时作为会话归属;返回 designer-cli 形态的本地身份
143
+ # (clearance=secret / can_admin / role=designer / auth_mode=local-cli)——队列面
144
+ # 的 review_list / review_decide 需要 review op 与 can_admin,故本守卫只用临时根。
145
+ def _princ(sess="sess-i50c"):
146
+ return Principal(tenant="t1", actor="designer-cli", clearance="secret",
147
+ can_write=True, can_admin=True, role="designer",
148
+ auth_mode="local-cli", session=sess)
149
+
150
+
151
+ # 生效条件:cg 有 inbox_log 属性;返回其 JSONL 全记录(不存在或空行即空列表);
152
+ # 单行畸形按跳过处理,不抛。
153
+ def _inbox(cg):
154
+ p = getattr(cg, "inbox_log", None)
155
+ if not p or not os.path.exists(p):
156
+ return []
157
+ out = []
158
+ with open(p, encoding="utf-8") as f:
159
+ for line in f:
160
+ line = line.strip()
161
+ if not line:
162
+ continue
163
+ try:
164
+ out.append(json.loads(line))
165
+ except ValueError:
166
+ continue
167
+ return out
168
+
169
+
170
+ # 生效条件:mode ∈ {"direct","defer"};在系统临时目录新建隔离根,建基线节点
171
+ # base 并按 mode 写入同名节点 NID(direct=全新内容⇒ACCEPT;defer=半重复⇒DEFER
172
+ # 且经 review_decide(accept) 落盘)。产出 (fm, out, content, accept_result);
173
+ # 退出时先 close() 再无条件删根(异常路径也删)。
174
+ @contextlib.contextmanager
175
+ def _case(mode):
176
+ root = tempfile.mkdtemp(prefix="i50c_%s_" % mode)
177
+ cg = None
178
+ try:
179
+ # 两个根用**同一**会话串:会话归属同为「同一声明」的一部分,不引入
180
+ # 与被测修复无关的 fm.session 差异源。
181
+ cg = MdCGSecure(root, principal=_princ("sess-i50c"), autoflush=1)
182
+ cg.add("base", _doc(T_BASE, BASE), layer="knowledge", importance=0.5,
183
+ role="user")
184
+ content = _doc(T_NEW, NOVEL if mode == "direct" else _mixed(K_HALF))
185
+ out = cg.remember_gated(NID, content, layer="knowledge", **META)
186
+ acc = None
187
+ if mode == "defer" and out.get("proposed"):
188
+ acc = cg.review_decide(out["proposed"], "accept", reason="i50c 守卫")
189
+ fm = dict(((cg.get(NID) or {}).get("frontmatter")) or {})
190
+ yield fm, out, content, acc
191
+ finally:
192
+ with contextlib.suppress(Exception):
193
+ if cg is not None:
194
+ cg.close()
195
+ shutil.rmtree(root, ignore_errors=True)
196
+
197
+
198
+ # 生效条件:无输入;返回 (fm_direct, fm_defer, out_direct, out_defer, acc_defer)。
199
+ def _pair():
200
+ with _case("direct") as (fd, od, _c1, _a1):
201
+ pass
202
+ with _case("defer") as (fz, oz, _c2, az):
203
+ pass
204
+ return fd, fz, od, oz, az
205
+
206
+
207
+ # 白名单(键 → 理由)。B 组要求 diff ⊆ 本表,且条目数恰 5。
208
+ _WHITELIST = {
209
+ "defer_reason": "issue50-b 的「为何待定」文案,仅 DEFER 路有(契约已裁定接受)",
210
+ "reviewer": "accept 路径的裁决归属留痕(mdcos.py:2571-2572);硬边界②不许削弱",
211
+ "created_at": "写入时刻时间戳:直接路写入即落盘 / DEFER 路裁决 accept 时落盘",
212
+ "condition_space": "仅派生键 time_window(= created_at 起算)随之变;其余键见 B3",
213
+ # issue50-d(2026-10-02):裁决面衍生键,仅 ACCEPT 直接路有——DEFER 路经
214
+ # 队列 accept 落盘时不存在本闸的 ACCEPT 裁决,该键无从产生;不是调用方
215
+ # 声明的 meta,不在 F1「声明不丢」的判据面内(B2 已排除)。
216
+ "importance_source": "issue50-d 的落盘重要度来源标注,仅直接 ACCEPT 路有",
217
+ }
218
+
219
+ # 契约点名的六个声明键 → 直接 ACCEPT 路应落在的那些 fm 键。
220
+ _NAMED = (("tags", "tags"),
221
+ ("condition_space", "condition_space"),
222
+ ("verification_basis", "verification_basis"),
223
+ ("non_applicable_conditions", "non_applicable_conditions"),
224
+ ("derived_from", "derived_from"),
225
+ ("relation", "derived_relation"))
226
+ # 佐证键(同一声明的其余 meta 也应等价;`derived_relation` 已随 `_NAMED` 的
227
+ # relation 检查,不重复计)。
228
+ _EXTRA_KEYS = ("role", "bucket_zh")
229
+
230
+
231
+ # ---------------------------------------------------------------- A 修复面
232
+ def g_a():
233
+ print("== A 修复面:DEFER→accept 的声明 meta 与直接 ACCEPT 逐位相等 ==")
234
+ fd, fz, od, oz, az = _pair()
235
+ ok(od.get("verdict") == "ACCEPT" and bool(od.get("written")),
236
+ "A0 对照路(全新内容)verdict=ACCEPT 且已落盘", od.get("verdict"))
237
+ ok(oz.get("verdict") == "DEFER" and bool(oz.get("proposed")),
238
+ "A0b 被测路(半重复)verdict=DEFER 且已入队", oz.get("verdict"))
239
+ ok(bool(az and az.get("ok")), "A0c DEFER 提案 accept 成功", az)
240
+ # 契约点名的六键:必须**在位且相等**(同时防「两路都缺」的假等价)。
241
+ # condition_space 按「除派生 time_window 外」比较(该键是写入时刻的函数,
242
+ # 见白名单;其非 time_window 部分必须逐位相等,故这里要求非空)。
243
+ for want, key in _NAMED:
244
+ if key == "condition_space":
245
+ va = {k: v for k, v in (fz.get(key) or {}).items()
246
+ if k != "time_window"}
247
+ vb = {k: v for k, v in (fd.get(key) or {}).items()
248
+ if k != "time_window"}
249
+ ok(key in fz and key in fd and bool(vb) and va == vb,
250
+ "A1[%s] 在位且逐位相等(除派生 time_window)" % want,
251
+ (key in fz, key in fd, va, vb))
252
+ continue
253
+ # relation 经 add 落到 fm.derived_relation;其余同名。
254
+ ok(key in fz and key in fd and fz.get(key) == fd.get(key),
255
+ "A1[%s] 在位且逐位相等" % want,
256
+ (key in fz, key in fd, fz.get(key), fd.get(key)))
257
+ for key in _EXTRA_KEYS:
258
+ ok(key in fz and key in fd and fz.get(key) == fd.get(key),
259
+ "A3[%s] 佐证键在位且逐位相等" % key,
260
+ (key in fz, key in fd, fz.get(key), fd.get(key)))
261
+
262
+
263
+ # ---------------------------------------------------------------- B 白名单
264
+ def g_b():
265
+ print("== B 白名单外差异为 0(逐键对拍表)==")
266
+ fd, fz, _od, _oz, _az = _pair()
267
+ keys = sorted(set(fd) | set(fz))
268
+ print(" %-28s | %-34s | %-34s | %s" % ("fm 键", "直接 ACCEPT", "DEFER→accept", "判定"))
269
+ print(" " + "-" * 112)
270
+ diff = []
271
+ for k in keys:
272
+ va, vb = fd.get(k, _ABSENT), fz.get(k, _ABSENT)
273
+ same = va == vb
274
+ if not same:
275
+ diff.append(k)
276
+ sa = "<absent>" if va is _ABSENT else json.dumps(va, ensure_ascii=False, default=str)
277
+ sb = "<absent>" if vb is _ABSENT else json.dumps(vb, ensure_ascii=False, default=str)
278
+ if len(sa) > 32:
279
+ sa = sa[:29] + "..."
280
+ if len(sb) > 32:
281
+ sb = sb[:29] + "..."
282
+ print(" %-28s | %-34s | %-34s | %s"
283
+ % (k, sa, sb, "同" if same else ("白名单" if k in _WHITELIST else "**差异**")))
284
+ outside = [k for k in diff if k not in _WHITELIST]
285
+ ok(not outside, "B1 白名单外的 fm 差异为 0(逐行读数)", outside)
286
+ # issue50-d 的 `importance_source` 是裁决面衍生键(仅直接 ACCEPT 路有,
287
+ # 见白名单理由),不属 F1「声明 meta 不丢」的判据面——B2 排除它。
288
+ only_direct = [k for k in keys if k in fd and k not in fz
289
+ and k != "importance_source"]
290
+ ok(not only_direct,
291
+ "B2 DEFER 路不缺任何声明 meta 键(无 only-in-DIRECT;"
292
+ "issue50-d 的 importance_source 除外)", only_direct)
293
+ cs_z = {k: v for k, v in (fz.get("condition_space") or {}).items()
294
+ if k != "time_window"}
295
+ cs_d = {k: v for k, v in (fd.get("condition_space") or {}).items()
296
+ if k != "time_window"}
297
+ ok(cs_z == cs_d and bool(cs_d),
298
+ "B3 condition_space 的差异**仅限**派生的 time_window", (cs_z, cs_d))
299
+ tw_z = (fz.get("condition_space") or {}).get("time_window")
300
+ tw_d = (fd.get("condition_space") or {}).get("time_window")
301
+ ok(isinstance(tw_z, list) and isinstance(tw_d, list) and len(tw_z) == 2
302
+ and len(tw_d) == 2 and float(tw_z[0]) >= float(tw_d[0]),
303
+ "B4 time_window 确为两次写入时刻(差异真实存在,非被掩盖)",
304
+ (tw_d, tw_z))
305
+ ok(len(_WHITELIST) == 5 and all(k in diff for k in _WHITELIST),
306
+ "B5 白名单恰 5 条且每条都真实命中(无空挂;第 5 条=issue50-d 的"
307
+ " importance_source)", (len(_WHITELIST), diff))
308
+
309
+
310
+ # ---------------------------------------------------------------- C None 捩点
311
+ def g_c():
312
+ print("== C node_id=None 捩点收口(不崩、不静默)==")
313
+ root = tempfile.mkdtemp(prefix="i50c_none_")
314
+ cg = None
315
+ try:
316
+ cg = MdCGSecure(root, principal=_princ("sess-i50c-none"), autoflush=1)
317
+ cg.add("base", _doc(T_BASE, BASE), layer="knowledge", importance=0.5,
318
+ role="user")
319
+ content = _doc(T_NEW, _mixed(K_HALF))
320
+ try:
321
+ out = cg.remember_gated(None, content, layer="knowledge", **META)
322
+ exc = None
323
+ except Exception as e: # 改前此处为 TypeError
324
+ out, exc = {}, e
325
+ ok(exc is None, "C1 node_id=None 不抛异常(改前 TypeError)", repr(exc))
326
+ ok(out.get("verdict") == "DEFER", "C2 半重复仍判 DEFER", out.get("verdict"))
327
+ ok(out.get("node_id") is None,
328
+ "C3 返回体 node_id 原样为 None(不伪装成派生 id)", out.get("node_id"))
329
+ exp = forgetting._prefeed_id(content)
330
+ recs = [r for r in _inbox(cg) if r.get("content") == content]
331
+ ok(len(recs) == 1 and recs[0].get("id") == exp and exp.startswith("pre_"),
332
+ "C4 仍入队且条目 id = 内容派生 id(forgetting._prefeed_id 同款)",
333
+ (len(recs), (recs[0].get("id") if recs else None), exp))
334
+ ok(bool(out.get("proposed")) and out.get("proposed") == (recs[0].get("pid") if recs else None),
335
+ "C5 返回体 proposed == 队列 pid(收件箱不因缺 id 而丢)", out.get("proposed"))
336
+ ok(out.get("proposed_id_fallback") == exp,
337
+ "C6 兜底事实在返回体如实标注(非静默)", out.get("proposed_id_fallback"))
338
+ acc = cg.review_decide(out.get("proposed"), "accept", reason="i50c 守卫")
339
+ ok(bool(acc and acc.get("ok")) and acc.get("node_id") == exp
340
+ and cg.get(exp) is not None and (cg.get(exp) or {}).get("content") == content,
341
+ "C7 accept 后落盘节点 id 即派生 id 且正文逐字相等(闭环可用)", acc)
342
+ finally:
343
+ with contextlib.suppress(Exception):
344
+ if cg is not None:
345
+ cg.close()
346
+ shutil.rmtree(root, ignore_errors=True)
347
+
348
+
349
+ # ---------------------------------------------------------------- D 既有职责
350
+ def g_d():
351
+ print("== D 裁决既有职责不削弱(本批扩面的边界)==")
352
+ root = tempfile.mkdtemp(prefix="i50c_duty_")
353
+ cg = None
354
+ try:
355
+ cg = MdCGSecure(root, principal=_princ("sess-i50c-duty"), autoflush=1)
356
+ cg.add("base", _doc(T_BASE, BASE), layer="knowledge", importance=0.5,
357
+ role="user")
358
+ p1 = cg.propose("d1", _doc("候选一", _mixed(K_HALF)), layer="knowledge",
359
+ tags=["t1"], verification_basis="test")
360
+ r1 = cg.review_decide(p1, "accept", reason="i50c")
361
+ ok(bool(r1.get("ok")), "D1 accept 落盘成功", r1)
362
+ r2 = cg.review_decide(p1, "accept", reason="i50c")
363
+ ok(r2.get("ok") is False and r2.get("error") == "already_decided",
364
+ "D2 幂等对账/终态仍在(同 pid 再裁 already_decided)", r2)
365
+ p2 = cg.propose("d2", _doc("候选二", _mixed(K_HALF)), layer="knowledge",
366
+ verification_basis="test")
367
+ r3 = cg.review_decide(p2, "edit", edits={"verify": {"kind": "x"}},
368
+ reason="i50c")
369
+ ok(r3.get("ok") is False and r3.get("error") == "verify_readonly",
370
+ "D3 自验违例检测仍在(裁决期改判据 verify_readonly)", r3)
371
+ r4 = cg.review_decide(p2, "reject", reason="i50c")
372
+ ok(bool(r4.get("ok")) and cg.get("d2") is None, "D4 reject 仍不落盘", r4)
373
+ p3 = cg.propose("d3", _doc("候选三", _mixed(K_HALF)), layer="knowledge")
374
+ r5 = cg.review_decide(p3, "noop", reason="i50c")
375
+ ok(bool(r5.get("ok")) and cg.get("d3") is None, "D5 noop 仍不落盘(只留痕)",
376
+ r5)
377
+ p4 = cg.propose("m9", _doc("候选四", _mixed(K_HALF)), layer="knowledge",
378
+ non_applicable_conditions=["乙"])
379
+ r6 = cg.review_decide(p4, "merge", merge_into="base", reason="i50c")
380
+ fm_b = ((cg.get("base") or {}).get("frontmatter") or {})
381
+ ok(bool(r6.get("ok")) and "乙" in (fm_b.get("non_applicable_conditions") or []),
382
+ "D6 merge 仍把不适用条件并集落目标 fm(既有职责)",
383
+ (r6, fm_b.get("non_applicable_conditions")))
384
+ ok(any(d.get("pid") == p1 for d in cg.decisions()),
385
+ "D7 裁决留痕仍在(decisions.jsonl 有该 pid)")
386
+ finally:
387
+ with contextlib.suppress(Exception):
388
+ if cg is not None:
389
+ cg.close()
390
+ shutil.rmtree(root, ignore_errors=True)
391
+
392
+
393
+ _GROUPS = (g_a, g_b, g_c, g_d)
394
+
395
+
396
+ def _run_fails():
397
+ """跑全部断言组(静默),返回失败断言名列表——供变异自证复用。"""
398
+ _PASS.clear()
399
+ _FAIL.clear()
400
+ with contextlib.redirect_stdout(io.StringIO()):
401
+ for g in _GROUPS:
402
+ g()
403
+ return list(_FAIL)
404
+
405
+
406
+ def _run_groups():
407
+ return len(_run_fails())
408
+
409
+
410
+ # ---------------------------------------------------------------- 定点变异自证
411
+ # 表内每项 = (说明, 目标类, 方法名, 锚点原文, 替换文, 预期红项数)。锚点必须**逐字**
412
+ # 出现在当前盘实现的源码里;漂移即 ANCHOR-MISS(fail-closed,exit 2)。
413
+ _PROPOSE = (' out["proposed"] = self.propose(\n'
414
+ ' _nid, content, layer=layer, **kw, '
415
+ 'defer_reason=_why)')
416
+ _NID = (' _nid = node_id if isinstance(node_id, str) and node_id else \\\n'
417
+ ' forgetting._prefeed_id(content)')
418
+ _FALLBACK_MARK = (' if _nid != node_id:\n'
419
+ ' out["proposed_id_fallback"] = _nid')
420
+ _ALREADY = (' if pid in self._closed_pids():\n'
421
+ ' return {"ok": False, "error": "already_decided"}')
422
+ _VERIFY_RO = (' if (edits and "verify" in edits) or (redteam and "verify" in redteam):')
423
+ _NOOP = ' if decision == DECISION_NOOP:'
424
+ _NEG_UNION = (' neg = list(fm.get("non_applicable_conditions") or [])\n'
425
+ ' for x in (item.get("extra") or {}).get("non_applicable_conditions") or []:\n'
426
+ ' if x not in neg:\n'
427
+ ' neg.append(x)\n'
428
+ ' fm["non_applicable_conditions"] = neg')
429
+ _TAGS_MAP = ' tags = list(item.get("tags") or [])'
430
+ _CS_MAP = ' condition_space=item.get("condition_space"), **extra)'
431
+
432
+ _MUTATIONS = (
433
+ # ① 契约指定:DEFER 出口**不再透传 meta**(退回 issue50-b 的只传 sensitivity)。
434
+ # 实测红项 11:A1 六键全部(tags/condition_space/verification_basis/
435
+ # non_applicable_conditions/derived_from/relation=6)+ A3 的 role/bucket_zh(2;
436
+ # bucket_zh 因 tags/cs 双缺而变)+ B1(白名单外差异)+ B2(only-in-DIRECT 非空)
437
+ # + B3(cs 非 time_window 键不等)。
438
+ ("DEFER 出口不透传 meta(退回只传 sensitivity)",
439
+ MdCGOS, "remember_gated", _PROPOSE,
440
+ ' out["proposed"] = self.propose(\n'
441
+ ' _nid, content, layer=layer,\n'
442
+ ' sensitivity=kw.get("sensitivity"), defer_reason=_why)',
443
+ 11),
444
+ # ② 只丢 condition_space(其余 meta 仍在)——打红「cs 相等」那条。实测 5:
445
+ # A1[condition_space] + A3[bucket_zh](**route_key 读 cs.observation_position**
446
+ # ⇒ 桶/别名随之变,是本表的连带项而非噪声)+ B1 + B2 + B3。
447
+ ("只丢 condition_space(连带桶路由变化)",
448
+ MdCGOS, "remember_gated", _PROPOSE,
449
+ ' out["proposed"] = self.propose(\n'
450
+ ' _nid, content, layer=layer,\n'
451
+ ' **{k: v for k, v in kw.items() if k != "condition_space"},\n'
452
+ ' defer_reason=_why)',
453
+ 5),
454
+ # ③ 只丢 derived_from——打红 derived_from 与它派生的 derived_relation。实测 4:
455
+ # A1[derived_from] + A1[relation](fm.derived_relation 缺席)+ B1 + B2。
456
+ ("只丢 derived_from(连同其派生 derived_relation)",
457
+ MdCGOS, "remember_gated", _PROPOSE,
458
+ ' out["proposed"] = self.propose(\n'
459
+ ' _nid, content, layer=layer,\n'
460
+ ' **{k: v for k, v in kw.items() if k != "derived_from"},\n'
461
+ ' defer_reason=_why)',
462
+ 4),
463
+ # ④ 契约指定:去掉 None 兜底(`_nid` 直接取 node_id)→ propose 里 None + str 抛
464
+ # TypeError。实测 6:C1(抛异常)/C2/C4/C5/C6/C7 红;C3(node_id is None)在
465
+ # 空返回体下仍为真,故不红。
466
+ ("去掉 node_id=None 兜底(退回 TypeError)",
467
+ MdCGOS, "remember_gated", _NID,
468
+ ' _nid = node_id',
469
+ 6),
470
+ # ⑤ None 仍兜底但**静默**(不写返回体标注)。预期:C6 = 1。
471
+ ("None 兜底但静默(不写 proposed_id_fallback)",
472
+ MdCGOS, "remember_gated", _FALLBACK_MARK,
473
+ ' pass',
474
+ 1),
475
+ # ⑥ accept 不再映射 tags(削弱 review_decide 既有映射)——DEFER 路 tags 丢失,
476
+ # 且 bucket_zh 因 tags 空而变。实测 3:A1[tags] + A3[bucket_zh] + B1
477
+ # (B2 不红:tags 键仍在,只是值为 [])。
478
+ ("accept 不再映射 tags(削弱既有映射)",
479
+ MdCGOS, "review_decide", _TAGS_MAP,
480
+ ' tags = []',
481
+ 3),
482
+ # ⑦ accept 不再映射 condition_space。实测 5:A1[condition_space] +
483
+ # A3[bucket_zh](route_key 读 cs ⇒ 桶别名变)+ B1 + B2 + B3。
484
+ ("accept 不再映射 condition_space(削弱既有映射)",
485
+ MdCGOS, "review_decide", _CS_MAP,
486
+ ' condition_space=None, **extra)',
487
+ 5),
488
+ # ⑧ 去掉幂等对账(既有职责)。预期:D2 = 1。
489
+ ("去掉幂等对账 already_decided(既有职责)",
490
+ MdCGOS, "review_decide", _ALREADY,
491
+ ' if False:\n'
492
+ ' return {"ok": False, "error": "already_decided"}',
493
+ 1),
494
+ # ⑨ 去掉自验违例检测 verify_readonly(既有职责)。实测 2:D3(该检不报)+
495
+ # D4(连带——edit 不再被拦,把 d2 真写盘了,后面的 reject 断言「不落盘」随之红)。
496
+ ("去掉自验违例检测 verify_readonly(既有职责 连带 D4)",
497
+ MdCGOS, "review_decide", _VERIFY_RO,
498
+ ' if False:',
499
+ 2),
500
+ # ⑩ noop 不再短路(落到 else/merge 分支)。预期:D5 = 1。
501
+ ("noop 不再短路(落到 merge 分支)",
502
+ MdCGOS, "review_decide", _NOOP,
503
+ ' if False and decision == DECISION_NOOP:',
504
+ 1),
505
+ # ⑪ merge 不再做不适用条件并集(既有职责)。预期:D6 = 1。
506
+ ("merge 不再并集不适用条件(既有职责)",
507
+ MdCGOS, "review_decide", _NEG_UNION,
508
+ ' pass',
509
+ 1),
510
+ )
511
+
512
+ # 静态锚点(默认模式也自检,fail-closed):
513
+ _ANCHORS_REQUIRED = (
514
+ (MdCGOS, "remember_gated", '**kw, defer_reason=_why)'),
515
+ (MdCGOS, "remember_gated", 'elif v == "DEFER":'),
516
+ (MdCGOS, "remember_gated", 'forgetting._prefeed_id(content)'),
517
+ (MdCGOS, "review_decide", 'condition_space=item.get("condition_space"), **extra)'),
518
+ (MdCGOS, "review_decide", 'if pid in self._closed_pids():'),
519
+ )
520
+
521
+
522
+ def _src(cls, name):
523
+ return inspect.getsource(getattr(cls, name))
524
+
525
+
526
+ # 生效条件:无输入;逐条比对变异锚点与必需锚点是否仍逐字在位,返回 ANCHOR-MISS
527
+ # 说明列表(空 = 全部在位)。
528
+ def _anchor_check():
529
+ bad = []
530
+ for note, cls, name, old, _new, _n in _MUTATIONS:
531
+ if old not in _src(cls, name):
532
+ bad.append("变异锚点缺失:%s(锚点 %r)" % (note, old[:60]))
533
+ for cls, name, s in _ANCHORS_REQUIRED:
534
+ if s not in _src(cls, name):
535
+ bad.append("必需锚点缺失:%s.%s 内 %r" % (cls.__name__, name, s))
536
+ return bad
537
+
538
+
539
+ # 生效条件:old 与 new 给定时,取目标方法在当前盘实现的源码做字面替换,以模块
540
+ # globals 副本 exec 出新函数;返回该函数对象(不落盘、不改源文件)。方法体在类里
541
+ # 缩进 4 格,故用 `if True:` 前缀承接。
542
+ def _mutate(cls, name, old, new):
543
+ ns = dict(vars(sys.modules[cls.__module__]))
544
+ exec(compile("if True:\n" + _src(cls, name).replace(old, new),
545
+ "i50c_mut.py", "exec"), ns)
546
+ return ns[name]
547
+
548
+
549
+ # 生效条件:把 cls.name 临时换成 fn 跑一遍 run(),finally 原样还原。
550
+ def _with_patched(cls, name, fn, run):
551
+ live = getattr(cls, name)
552
+ setattr(cls, name, fn)
553
+ try:
554
+ return run()
555
+ finally:
556
+ setattr(cls, name, live)
557
+
558
+
559
+ def _mutate_mode():
560
+ bad = []
561
+ anchor_bad = _anchor_check()
562
+ if anchor_bad:
563
+ for b in anchor_bad:
564
+ print(" ANCHOR-MISS " + b)
565
+ print("\n锚点自检:FAIL(fail-closed,exit 2)")
566
+ return 2
567
+ clean = _run_groups()
568
+ print(" 未变异基线:红项=%d%s"
569
+ % (clean, "" if clean == 0 else " ← 基线即红,变异核验无意义"))
570
+ if clean:
571
+ bad.append("未变异基线即失败")
572
+ for note, cls, name, old, new, expect in _MUTATIONS:
573
+ fn = _mutate(cls, name, old, new)
574
+ fails = _with_patched(cls, name, fn, _run_fails)
575
+ verdict = ("命中预期" if len(fails) == expect
576
+ else "**红项数不符(预期 %d)**" % expect)
577
+ print(" 变异「%s」→ 红项=%d %s" % (note, len(fails), verdict))
578
+ if len(fails) != expect:
579
+ bad.append(note)
580
+ print(" 实际红项:" + "、".join(fails))
581
+ print("\n定点变异自证:%s"
582
+ % ("PASS(每处判据都有变异钉死,且红项数逐处吻合)"
583
+ if not bad else "FAIL —— " + "、".join(bad)))
584
+ return 0 if not bad else 1
585
+
586
+
587
+ def main() -> int:
588
+ src = os.path.basename(os.path.abspath(__file__))
589
+ if "--mutate" in sys.argv:
590
+ print("!! 定点变异模式:逐个变异判据,套件应转红且红项数吻合\n")
591
+ return _mutate_mode()
592
+ anchor_bad = _anchor_check()
593
+ if anchor_bad:
594
+ for b in anchor_bad:
595
+ print(" ANCHOR-MISS " + b)
596
+ print("\n锚点自检:FAIL(fail-closed,exit 2)——实现改了请同步变异表/文案")
597
+ return 2
598
+ print("锚点自检:PASS(%s;不以 git HEAD 为基线源)" % src)
599
+ for g in _GROUPS:
600
+ g()
601
+ print("\nissue50-c 守卫:%d 通过,%d 失败" % (len(_PASS), len(_FAIL)))
602
+ if _FAIL:
603
+ print("失败项:" + "、".join(_FAIL))
604
+ return 0 if not _FAIL else 1
605
+
606
+
607
+ if __name__ == "__main__":
608
+ sys.exit(main())