@furongjun1999/dsh-memory 0.4.11 → 0.5.0

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 (504) hide show
  1. package/README.md +16 -16
  2. package/codebuddy/CODEBUDDY.md +11 -3
  3. package/codebuddy/README.md +92 -90
  4. package/codebuddy/mcp.json +27 -27
  5. package/docs/GBrain/345/217/257/345/200/237/351/211/264/347/202/271_/347/201/265/346/236/242/350/220/275/347/202/271/344/272/244/346/216/245_20260919.md +169 -169
  6. package/docs/Pi/345/217/257/345/255/246/344/271/240/344/274/230/347/202/271_/347/201/265/346/236/242/345/244/247/350/204/221/346/224/271/350/277/233/344/272/244/346/216/245_20260915.md +144 -144
  7. package/docs/README.md +142 -111
  8. package/docs/discipline/harnesses.yaml +244 -226
  9. package/docs/discipline/templates/full.md.tmpl +61 -61
  10. package/docs/discipline/templates/rules.mdc.tmpl +68 -0
  11. package/docs/discipline/templates/skill.md.tmpl +23 -23
  12. package/docs/eval/AGI/344/270/203/347/273/264/350/257/204/345/210/206/346/212/245/345/221/212_md_cg_v1.0.md +299 -299
  13. package/docs/eval/AGI/344/270/203/347/273/264/350/257/204/345/210/206/346/212/245/345/221/212_md_cg_v2.0.md +239 -239
  14. package/docs/eval//345/256/236/351/252/214/346/226/271/346/241/210_/345/255/246/344/271/240/351/227/255/347/216/257AB/344/270/216/346/250/252/350/257/204_v1.0.md +174 -174
  15. package/docs/eval//346/250/252/350/257/204_/345/205/255/345/256/266100/351/242/230/344/270/255/350/213/261/345/217/214/346/237/245_v1.0.md +223 -223
  16. package/docs/{mdcg → hive}//344/273/244/347/211/214/344/270/216/350/247/222/350/211/262/346/235/203/350/201/214/345/210/206/347/246/273_v0.1.md +165 -160
  17. package/docs/hive//344/273/244/347/211/214/350/257/255/344/271/211/344/277/256/346/255/243_/344/273/262/350/243/201/344/275/215_v0.1.md +76 -0
  18. package/docs/{mdcg → hive}//345/255/220/344/273/243/347/220/206/351/205/215/347/275/256/346/240/207/345/207/206_v0.5.md +236 -236
  19. package/docs/hive//346/243/200/347/264/242/346/224/266/346/225/233/345/256/236/346/265/213/344/270/216S1b/350/256/276/350/256/241_v0.1.md +43 -43
  20. 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 +85 -0
  21. 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 +102 -102
  22. package/docs/hive//350/234/202/345/267/242M6_ingest/345/256/236/346/226/275/350/256/241/345/210/222_v0.1.md +47 -0
  23. package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +503 -503
  24. package/docs/hive//350/234/202/345/267/242/345/267/245/344/275/234/350/256/260/345/277/206_/351/241/271/347/233/256/350/256/241/345/210/222.md +85 -85
  25. package/docs/hive//350/234/202/345/267/242/350/256/276/350/256/241_/347/220/206/350/256/272/345/257/271/351/275/220_v0.1.md +191 -0
  26. package/docs/hive//350/234/202/345/267/242/350/277/255/344/273/243_/345/256/217/350/247/202/344/270/216/347/276/244/344/275/223/350/260/203/345/272/246_v0.1.md +90 -0
  27. package/docs/mdcg/D_meta_/345/267/245/347/250/213/345/214/226/346/226/271/346/241/210_v0.2.md +216 -216
  28. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +646 -646
  29. package/docs/mdcg/lingshu_tutorial.html +14449 -14449
  30. package/docs/mdcg/release_v0.4.11.md +49 -0
  31. package/docs/mdcg/release_v0.4.5.md +55 -55
  32. package/docs/mdcg/tool_table_v0.3.0.md +117 -117
  33. package/docs/mdcg//345/205/250/345/272/223/344/273/243/347/240/201/350/257/204/345/256/241/344/270/216/346/235/241/344/273/266/345/214/226/346/263/250/351/207/212_/350/256/241/345/210/222_v0.1.md +600 -600
  34. 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
  35. package/docs/mdcg//345/215/225/345/205/203/350/207/252/346/210/221/351/224/232/347/202/271_/347/263/273/347/273/237/346/217/220/347/244/272/350/257/215/346/240/207/345/207/206_v0.3.md +275 -275
  36. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +54 -0
  37. package/docs/mdcg//346/272/220/347/240/201/347/272/247/346/236/266/346/236/204/345/256/241/350/256/241_GPT/346/211/271/350/257/204/345/257/271/347/205/247_v1.0.md +130 -130
  38. package/docs/mdcg//347/201/265/346/236/24282/345/267/245/345/205/267_/345/212/237/350/203/275/346/225/264/347/220/206/344/270/216/350/277/201/347/247/273/346/230/240/345/260/204_v0.1.md +278 -278
  39. package/docs/mdcg//347/201/265/346/236/242/350/256/260/345/277/206/345/212/250/350/257/215/345/215/217/350/256/256_v1.0-draft.md +172 -172
  40. package/docs/mdcg//347/274/272/345/217/243/345/215/225_P0/346/224/266/345/217/243_v0.1.md +270 -270
  41. package/docs/mdcg//350/256/244/347/237/245/345/233/276_G4-G8/347/274/272/345/217/243/350/243/201/345/256/232/345/215/225_v0.1.md +491 -491
  42. package/docs/mdcg//350/256/244/347/237/245/345/233/276_/346/235/241/344/273/266/347/251/272/351/227/264/345/220/210/346/210/220/344/270/216/347/224/237/346/225/210/346/235/241/344/273/266/345/217/243/345/276/204_v0.1.md +340 -340
  43. package/docs/mdcg//350/256/244/347/237/245/345/233/276_/347/264/242/345/274/225/344/270/216/345/267/245/347/250/213/350/247/204/350/214/203/345/214/226_/350/256/241/345/210/222_v0.1.md +588 -588
  44. package/docs/plans/GridWorld/346/234/200/345/260/217/351/227/255/347/216/257/350/247/204/346/240/274_v0.1.md +176 -176
  45. package/docs/plans//345/221/275/345/220/215/346/262/273/347/220/206_/351/241/271/347/233/256/350/256/241/345/210/222.md +81 -81
  46. package/docs/swarm//350/234/202/347/276/244/344/272/222/350/201/224_v0.1.md +704 -704
  47. package/docs/swarm//350/234/202/347/276/244/345/220/214/351/224/231/346/243/200/346/265/213/345/256/236/351/252/214/345/215/217/350/256/256_v0.1.md +242 -242
  48. package/docs/theory//345/215/225/347/272/277/347/250/213/344/270/216/346/263/250/346/204/217/345/212/233/351/233/206/344/270/255_/346/227/240/344/272/211/350/256/256/347/220/206/350/256/272/346/226/207/346/241/243_v1.0.md +129 -129
  49. package/docs/theory//345/271/266/345/217/221/345/277/205/347/204/266/346/200/247/347/220/206/350/256/272_v0.2.md +134 -134
  50. package/docs/theory//346/231/272/350/203/275/350/256/2723.4.md +5260 -5260
  51. package/docs/theory//346/246/202/345/277/265/345/210/206/345/261/202/345/257/271/351/275/220/350/241/250_v0.1.md +145 -145
  52. package/docs/theory//347/220/206/350/256/272_/346/234/272/345/210/266_/344/273/243/347/240/201_/345/256/236/351/252/214_/347/274/272/345/217/243/347/237/251/351/230/265_v0.1.md +144 -144
  53. package/docs/theory//347/220/206/350/256/272/344/273/223/346/213/206/345/210/206/344/270/216/347/231/275/347/256/261/347/237/245/350/257/206/345/272/223/345/206/205/350/277/201_v0.1.md +198 -198
  54. package/docs/theory//350/256/244/347/237/245/344/273/243/347/220/206/344/270/216/346/224/266/346/225/233/347/273/223/346/236/204_/346/235/241/344/273/266/350/256/272/351/207/215/346/236/204_v0.1.md +221 -221
  55. package/docs/world_model//344/270/226/347/225/214/346/250/241/345/236/213_/347/245/236/347/273/217/347/275/221/347/273/234/345/272/225/345/261/202/346/236/266/346/236/204_v1.0.md +237 -237
  56. package/docs//345/217/221/345/270/203/344/273/266/345/233/236/346/272/257/350/257/264/346/230/216_v0.1.md +82 -82
  57. package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +28 -2
  58. package/dsh/README.md +82 -82
  59. package/dsh/cordis.yml.example +139 -139
  60. package/dsh/update-lingshu.bat +11 -11
  61. package/lib/hooks.js +36 -2
  62. package/lib/lib/roleplay_web.js +427 -427
  63. package/md_cg/__init__.py +7 -7
  64. package/md_cg/audit.py +368 -368
  65. package/md_cg/autonomy.py +287 -287
  66. package/md_cg/backfill.py +1327 -1327
  67. package/md_cg/backfill_bigdomain.py +34 -34
  68. package/md_cg/bench6_arms.py +410 -410
  69. package/md_cg/bench6_common.py +230 -230
  70. package/md_cg/bench6_competitors.py +212 -212
  71. package/md_cg/bench_axis_domain.py +257 -257
  72. package/md_cg/bench_blind_comp.py +308 -308
  73. package/md_cg/bench_en_atoms_public.py +230 -230
  74. package/md_cg/bench_governance.py +348 -348
  75. package/md_cg/bench_lme_zh.py +410 -410
  76. package/md_cg/bench_locomo.py +121 -121
  77. package/md_cg/bench_locomo_zh.py +450 -450
  78. package/md_cg/bench_locomo_zh_public.py +147 -147
  79. package/md_cg/bench_longmem.py +112 -112
  80. package/md_cg/bench_membench.py +632 -632
  81. package/md_cg/bench_p0.py +149 -149
  82. package/md_cg/bench_progressive.py +287 -287
  83. package/md_cg/bench_role_views.py +238 -238
  84. package/md_cg/bench_task_ab.py +243 -243
  85. package/md_cg/bench_task_ab_llm.py +408 -408
  86. package/md_cg/bench_unified_en.py +204 -204
  87. package/md_cg/bench_zh_mad.py +601 -601
  88. package/md_cg/blindspot_tickets.py +123 -123
  89. package/md_cg/branches.py +285 -285
  90. package/md_cg/build_postings.py +73 -73
  91. package/md_cg/ccgc.py +1005 -948
  92. package/md_cg/census.py +132 -132
  93. package/md_cg/chain.py +300 -300
  94. package/md_cg/codeindex.py +531 -531
  95. package/md_cg/coldverify.py +292 -292
  96. package/md_cg/comment_gate.py +337 -337
  97. package/md_cg/cond_compose.py +190 -190
  98. package/md_cg/cond_facts.py +154 -154
  99. package/md_cg/cond_template.json +106 -106
  100. package/md_cg/condition_anchor.py +142 -142
  101. package/md_cg/conformance.py +726 -726
  102. package/md_cg/consistency.py +717 -717
  103. package/md_cg/consolidate.py +1536 -1439
  104. package/md_cg/corpus.py +110 -110
  105. package/md_cg/crosscheck.py +1097 -1097
  106. package/md_cg/crypto.py +437 -437
  107. package/md_cg/d_meta.py +310 -310
  108. package/md_cg/datapath.py +334 -334
  109. package/md_cg/docindex.py +473 -473
  110. package/md_cg/eval_common.py +575 -575
  111. package/md_cg/evidence.py +580 -580
  112. package/md_cg/evolution.py +477 -477
  113. package/md_cg/export.py +220 -220
  114. package/md_cg/forgetting.py +581 -581
  115. package/md_cg/fsutil.py +329 -329
  116. package/md_cg/hotcache.py +238 -214
  117. package/md_cg/hyperedge.py +251 -251
  118. package/md_cg/identity.py +390 -390
  119. package/md_cg/insight.py +500 -500
  120. package/md_cg/interop.py +199 -0
  121. package/md_cg/lexicon/build_cedict_en_zh.py +329 -329
  122. package/md_cg/lexicon/build_standard_en.py +171 -171
  123. package/md_cg/lexicon/expand_en_zh.py +211 -211
  124. package/md_cg/lifecycle.py +272 -272
  125. package/md_cg/linkref.py +280 -280
  126. package/md_cg/links.py +622 -622
  127. package/md_cg/mcp_server.py +129 -32
  128. package/md_cg/md_whitebox.py +345 -345
  129. package/md_cg/mdcg.py +176 -117
  130. package/md_cg/mdcos.py +79 -13
  131. package/md_cg/metacognition.py +591 -591
  132. package/md_cg/migrate.py +119 -119
  133. package/md_cg/migrate_aeis.py +221 -221
  134. package/md_cg/migrate_roleplay.py +293 -293
  135. package/md_cg/migrate_wisdom_graph.py +360 -360
  136. package/md_cg/mreview/__init__.py +25 -25
  137. package/md_cg/mreview/__main__.py +110 -110
  138. package/md_cg/mreview/bundle.py +178 -178
  139. package/md_cg/mreview/candidates.py +262 -262
  140. package/md_cg/mreview/govern.py +693 -693
  141. package/md_cg/mreview/locate.py +939 -939
  142. package/md_cg/mreview/pipeline.py +728 -728
  143. package/md_cg/mreview/rules/duplication.json +21 -21
  144. package/md_cg/mreview/rules/field_coverage.json +54 -54
  145. package/md_cg/mreview/rules/source_license.json +21 -21
  146. package/md_cg/mreview/rules/template_flow.json +21 -21
  147. package/md_cg/mreview/ruleset.py +252 -252
  148. package/md_cg/nodefile.py +575 -575
  149. package/md_cg/pooling.py +484 -472
  150. package/md_cg/postings.py +298 -298
  151. package/md_cg/predict.py +1100 -1100
  152. package/md_cg/progressive.py +123 -123
  153. package/md_cg/protect.py +272 -272
  154. package/md_cg/protocol/md_cg_gate.proto +33 -33
  155. package/md_cg/protocol.py +372 -372
  156. package/md_cg/provenance.py +582 -582
  157. package/md_cg/reach.py +453 -453
  158. package/md_cg/readcache.py +85 -0
  159. package/md_cg/refindex.py +833 -833
  160. package/md_cg/refine.py +604 -604
  161. package/md_cg/roleviews.py +89 -89
  162. package/md_cg/routing.py +365 -365
  163. package/md_cg/scrub.py +852 -852
  164. package/md_cg/security.py +274 -274
  165. package/md_cg/self_state.py +1029 -1029
  166. package/md_cg/selfreport.py +151 -151
  167. package/md_cg/semantic/__init__.py +10 -10
  168. package/md_cg/semantic/canonical.py +122 -122
  169. package/md_cg/semantic/en_normalizer.py +364 -364
  170. package/md_cg/semantic/en_zh_map.json +28694 -0
  171. package/md_cg/semantic/export_en_zh_map.py +64 -0
  172. package/md_cg/semantic/unify.py +45 -0
  173. package/md_cg/semantic/zh_en_atoms.py +139 -139
  174. package/md_cg/signer.py +562 -562
  175. package/md_cg/sources.py +815 -582
  176. package/md_cg/statushdr.py +179 -179
  177. package/md_cg/stg.py +48 -37
  178. package/md_cg/subgraph.py +729 -729
  179. package/md_cg/sustain.py +1138 -1138
  180. package/md_cg/tasks.py +470 -470
  181. package/md_cg/test_action_derive.py +203 -203
  182. package/md_cg/test_audit_rotate.py +270 -270
  183. package/md_cg/test_autonomy.py +143 -143
  184. package/md_cg/test_bench_governance.py +102 -102
  185. package/md_cg/test_blindspot_tickets.py +166 -166
  186. package/md_cg/test_branches.py +249 -249
  187. package/md_cg/test_ccg_perturb.py +184 -184
  188. package/md_cg/test_ccgc.py +433 -433
  189. package/md_cg/test_census_prune.py +81 -81
  190. package/md_cg/test_cond_compose_anchors.py +76 -76
  191. package/md_cg/test_cond_match.py +165 -165
  192. package/md_cg/test_condition_anchor.py +81 -81
  193. package/md_cg/test_d_meta.py +412 -412
  194. package/md_cg/test_datapath_root.py +199 -199
  195. package/md_cg/test_en_pipeline.py +166 -166
  196. package/md_cg/test_gain_gate.py +212 -212
  197. package/md_cg/test_health_scale.py +173 -173
  198. package/md_cg/test_hive_ingest.py +285 -0
  199. package/md_cg/test_hot_cold.py +215 -215
  200. package/md_cg/test_hyperedge.py +245 -245
  201. package/md_cg/test_i26_empty_first_write.py +116 -0
  202. package/md_cg/test_i27_e041_identity.py +128 -0
  203. package/md_cg/test_i28_hotcache_prodpath.py +122 -0
  204. package/md_cg/test_identity_attribution.py +147 -147
  205. package/md_cg/test_index_durability.py +224 -224
  206. package/md_cg/test_interop.py +93 -0
  207. package/md_cg/test_lifecycle.py +309 -309
  208. package/md_cg/test_linkref.py +306 -306
  209. package/md_cg/test_lock.py +43 -43
  210. package/md_cg/test_md_access_parity.py +255 -255
  211. package/md_cg/test_md_writepath.py +345 -345
  212. package/md_cg/test_mdstore_search_parity.py +160 -0
  213. package/md_cg/test_mr_m2.py +587 -587
  214. package/md_cg/test_mr_m3.py +710 -710
  215. package/md_cg/test_mr_m4.py +485 -485
  216. package/md_cg/test_p0.py +250 -250
  217. package/md_cg/test_p1.py +316 -316
  218. package/md_cg/test_p10_identity.py +173 -173
  219. package/md_cg/test_p11_consistency.py +233 -233
  220. package/md_cg/test_p12_metacognition.py +212 -212
  221. package/md_cg/test_p13_encryption.py +241 -241
  222. package/md_cg/test_p14_sustain.py +249 -249
  223. package/md_cg/test_p15_scrub.py +280 -280
  224. package/md_cg/test_p16_self_state.py +301 -301
  225. package/md_cg/test_p17_predict.py +354 -354
  226. package/md_cg/test_p18_whitebox.py +171 -171
  227. package/md_cg/test_p19_migrate_roleplay.py +149 -149
  228. package/md_cg/test_p20_evolution.py +315 -315
  229. package/md_cg/test_p21_tokens.py +293 -270
  230. package/md_cg/test_p22_theory.py +175 -175
  231. package/md_cg/test_p23_links.py +311 -311
  232. package/md_cg/test_p24_evidence.py +227 -227
  233. package/md_cg/test_p25_weights.py +156 -156
  234. package/md_cg/test_p26_refindex.py +416 -416
  235. package/md_cg/test_p27_docindex.py +765 -765
  236. package/md_cg/test_p28_refcheck.py +305 -305
  237. package/md_cg/test_p29_session_ingest_export.py +354 -333
  238. package/md_cg/test_p3.py +11 -2
  239. package/md_cg/test_p30_maintain.py +330 -330
  240. package/md_cg/test_p31_insight.py +534 -534
  241. package/md_cg/test_p32_backfill.py +298 -298
  242. package/md_cg/test_p33_ccg_wiring.py +293 -293
  243. package/md_cg/test_p34_crosscheck.py +331 -331
  244. package/md_cg/test_p35_conditioned_claim.py +252 -252
  245. package/md_cg/test_p36_kp_align.py +230 -230
  246. package/md_cg/test_p37_condition_space.py +248 -248
  247. package/md_cg/test_p38_concurrent_flush.py +102 -0
  248. package/md_cg/test_p38_contextualize.py +273 -273
  249. package/md_cg/test_p39_verify_flow.py +113 -0
  250. package/md_cg/test_p39_vision_evidence.py +369 -369
  251. package/md_cg/test_p40_refine_worklist.py +241 -241
  252. package/md_cg/test_p41_evolve_patrol.py +224 -224
  253. package/md_cg/test_p42_provenance.py +269 -269
  254. package/md_cg/test_p43_pooling.py +412 -398
  255. package/md_cg/test_p44_md_whitebox.py +231 -231
  256. package/md_cg/test_p45_session_identity.py +219 -219
  257. package/md_cg/test_p46_unit_scope.py +272 -272
  258. package/md_cg/test_p47_session_view.py +281 -0
  259. package/md_cg/test_p4_fuzzy.py +223 -223
  260. package/md_cg/test_p5_semantic.py +226 -226
  261. package/md_cg/test_p6_consolidate.py +440 -387
  262. package/md_cg/test_p7_goals_recent.py +202 -202
  263. package/md_cg/test_p8_subgraph_chain.py +200 -200
  264. package/md_cg/test_p9_forget_protect.py +231 -231
  265. package/md_cg/test_predict_beta.py +135 -135
  266. package/md_cg/test_preflight_failclosed.py +100 -100
  267. package/md_cg/test_progressive.py +146 -146
  268. package/md_cg/test_protocol.py +243 -243
  269. package/md_cg/test_reach.py +378 -378
  270. package/md_cg/test_reach_keys.py +201 -201
  271. package/md_cg/test_read_clip.py +141 -141
  272. package/md_cg/test_readcache_prodpath.py +155 -0
  273. package/md_cg/test_retr_gates_prodpath.py +140 -0
  274. package/md_cg/test_retr_s1.py +340 -340
  275. package/md_cg/test_retr_s1b.py +209 -209
  276. package/md_cg/test_retr_s3.py +194 -194
  277. package/md_cg/test_retr_s4.py +163 -163
  278. package/md_cg/test_retr_s5.py +200 -200
  279. package/md_cg/test_retr_s6.py +157 -157
  280. package/md_cg/test_retr_s7.py +384 -384
  281. package/md_cg/test_retr_s8_time.py +369 -316
  282. package/md_cg/test_retr_s9_edges.py +286 -286
  283. package/md_cg/test_retr_s9_entity_ctx.py +175 -175
  284. package/md_cg/test_review_conformance.py +367 -367
  285. package/md_cg/test_role_views.py +354 -354
  286. package/md_cg/test_sem_noise.py +242 -242
  287. package/md_cg/test_semantic_canonical.py +241 -241
  288. package/md_cg/test_subproc_encoding.py +192 -192
  289. package/md_cg/test_sustain_mutual.py +153 -153
  290. package/md_cg/test_tasks.py +409 -409
  291. package/md_cg/test_tool_face.py +189 -189
  292. package/md_cg/test_transfer.py +180 -180
  293. package/md_cg/test_trust.py +361 -361
  294. package/md_cg/test_twophase.py +286 -286
  295. package/md_cg/test_v14_fixes.py +397 -397
  296. package/md_cg/test_validity_filter.py +280 -280
  297. package/md_cg/test_verify_answer.py +138 -138
  298. package/md_cg/test_wisdom_md_store.py +292 -292
  299. package/md_cg/test_writelimit.py +197 -197
  300. package/md_cg/test_writepipe.py +214 -214
  301. package/md_cg/theory.py +273 -273
  302. package/md_cg/tokens.py +677 -663
  303. package/md_cg/tool_face.py +260 -260
  304. package/md_cg/trust.py +986 -950
  305. package/md_cg/twophase.py +231 -231
  306. package/md_cg/units.py +667 -667
  307. package/md_cg/vision_evidence.py +666 -666
  308. package/md_cg/weights.py +624 -624
  309. package/md_cg/whitebox.py +527 -527
  310. package/md_cg/whitebox_kb/__init__.py +37 -37
  311. package/md_cg/whitebox_kb/aeis_core/__init__.py +42 -42
  312. package/md_cg/whitebox_kb/aeis_core/semantic.py +280 -280
  313. package/md_cg/whitebox_kb/aeis_core/textutil.py +13 -13
  314. package/md_cg/whitebox_kb/engine.py +310 -310
  315. package/md_cg/whitebox_kb/seed_knowledge//346/231/272/350/203/275/350/256/2723.4.md +5260 -5260
  316. package/md_cg/whitebox_kb/wisdom/browser_units.py +2631 -2631
  317. package/md_cg/whitebox_kb/wisdom/causal_discover.py +432 -432
  318. package/md_cg/whitebox_kb/wisdom/chat_engine.py +1506 -1506
  319. package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
  320. package/md_cg/whitebox_kb/wisdom/compiler_code_units.py +3033 -3033
  321. package/md_cg/whitebox_kb/wisdom/condition_algebra.py +112 -112
  322. package/md_cg/whitebox_kb/wisdom/condition_frame.py +315 -315
  323. package/md_cg/whitebox_kb/wisdom/condition_kb.py +108 -108
  324. package/md_cg/whitebox_kb/wisdom/conflict_map.json +4445 -4445
  325. package/md_cg/whitebox_kb/wisdom/core/lexer.py +512 -512
  326. package/md_cg/whitebox_kb/wisdom/core/name_checker.py +1023 -1023
  327. package/md_cg/whitebox_kb/wisdom/cspmn.py +258 -258
  328. package/md_cg/whitebox_kb/wisdom/csre.py +264 -264
  329. package/md_cg/whitebox_kb/wisdom/danmaku_audit.py +252 -252
  330. package/md_cg/whitebox_kb/wisdom/distilled_condition_units.json +6417 -6417
  331. package/md_cg/whitebox_kb/wisdom/docs/WB-EVAL-20260902b.json +1950 -1950
  332. package/md_cg/whitebox_kb/wisdom/docs/WB-EVAL-20260902c.json +1296 -1296
  333. package/md_cg/whitebox_kb/wisdom/docs/whitebox_capability_graph_demo.json +59 -59
  334. package/md_cg/whitebox_kb/wisdom/graph_db_units.py +3089 -3089
  335. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +318 -318
  336. package/md_cg/whitebox_kb/wisdom/md_access.py +470 -470
  337. package/md_cg/whitebox_kb/wisdom/md_store.py +276 -251
  338. package/md_cg/whitebox_kb/wisdom/migrate_wisdom.py +476 -476
  339. package/md_cg/whitebox_kb/wisdom/navigate.py +248 -248
  340. package/md_cg/whitebox_kb/wisdom/neural_retrieve.py +224 -224
  341. package/md_cg/whitebox_kb/wisdom/os_units.py +2735 -2735
  342. package/md_cg/whitebox_kb/wisdom/pattern_separation.py +376 -376
  343. package/md_cg/whitebox_kb/wisdom/prereq_map.json +364 -364
  344. package/md_cg/whitebox_kb/wisdom/python_code_units.py +2766 -2766
  345. package/md_cg/whitebox_kb/wisdom/role_solidified.json +7 -7
  346. package/md_cg/whitebox_kb/wisdom/route_memory.py +244 -244
  347. package/md_cg/whitebox_kb/wisdom/scene_reconstruction.py +148 -148
  348. package/md_cg/whitebox_kb/wisdom/snr_report.json +40 -40
  349. package/md_cg/whitebox_kb/wisdom/test_code_compose_domains.py +7541 -7541
  350. package/md_cg/whitebox_kb/wisdom/test_compiler_self_bootstrap.py +354 -354
  351. package/md_cg/whitebox_kb/wisdom/test_ecosystem_assembly.py +158 -158
  352. package/md_cg/whitebox_kb/wisdom/test_ecosystem_demos.py +193 -193
  353. package/md_cg/whitebox_kb/wisdom/test_graph_db.py +292 -292
  354. package/md_cg/whitebox_kb/wisdom/test_python_self_bootstrap.py +160 -160
  355. package/md_cg/whitebox_kb/wisdom/trigger_words_index.json +4366 -4366
  356. package/md_cg/whitebox_kb/wisdom/verifier.py +1297 -1297
  357. package/md_cg/writelimit.py +356 -356
  358. package/md_cg/writepipe.py +550 -542
  359. package/package.json +97 -96
  360. package/skills/plugin.json +54 -54
  361. package/skills/skills/designer-perspective/SKILL.md +158 -158
  362. package/skills/skills/designer-perspective/references/01-observation-position.md +66 -66
  363. package/skills/skills/designer-perspective/references/02-structure-recognition.md +62 -62
  364. package/skills/skills/designer-perspective/references/03-direction-judgment.md +55 -55
  365. package/skills/skills/designer-perspective/references/04-qualification-verdict.md +72 -72
  366. package/skills/skills/designer-perspective/references/05-condition-attribution.md +74 -74
  367. package/skills/skills/designer-perspective/scripts/designer.py +545 -545
  368. package/skills/skills/designer-perspective/tests/cases.jsonl +17 -17
  369. package/skills/skills/designer-perspective/tests/selftest.py +61 -61
  370. package/skills/skills/lingshu-browser/SKILL.md +60 -60
  371. package/skills/skills/lingshu-compiler/SKILL.md +56 -56
  372. package/skills/skills/lingshu-compiler/units/analyze-type-infer/SKILL.md +45 -45
  373. package/skills/skills/lingshu-compiler/units/check-name-real/SKILL.md +45 -45
  374. package/skills/skills/lingshu-compiler/units/compile-assign/SKILL.md +45 -45
  375. package/skills/skills/lingshu-compiler/units/compile-expr-tree/SKILL.md +45 -45
  376. package/skills/skills/lingshu-compiler/units/compile-full-pipeline/SKILL.md +45 -45
  377. package/skills/skills/lingshu-compiler/units/compile-func-def/SKILL.md +45 -45
  378. package/skills/skills/lingshu-compiler/units/compile-if-then/SKILL.md +45 -45
  379. package/skills/skills/lingshu-compiler/units/compile-logic-expr/SKILL.md +45 -45
  380. package/skills/skills/lingshu-compiler/units/compile-recursive/SKILL.md +45 -45
  381. package/skills/skills/lingshu-compiler/units/compile-scope/SKILL.md +45 -45
  382. package/skills/skills/lingshu-compiler/units/compile-type-check/SKILL.md +45 -45
  383. package/skills/skills/lingshu-compiler/units/compile-while/SKILL.md +45 -45
  384. package/skills/skills/lingshu-compiler/units/compiler-0010c4bf/SKILL.md +45 -45
  385. package/skills/skills/lingshu-compiler/units/compiler-0355bffb/SKILL.md +45 -45
  386. package/skills/skills/lingshu-compiler/units/compiler-0361708a/SKILL.md +45 -45
  387. package/skills/skills/lingshu-compiler/units/compiler-054a0414/SKILL.md +45 -45
  388. package/skills/skills/lingshu-compiler/units/compiler-05a1691a/SKILL.md +45 -45
  389. package/skills/skills/lingshu-compiler/units/compiler-05eeed1e/SKILL.md +45 -45
  390. package/skills/skills/lingshu-compiler/units/compiler-0622a1f6/SKILL.md +45 -45
  391. package/skills/skills/lingshu-compiler/units/compiler-08b54217/SKILL.md +45 -45
  392. package/skills/skills/lingshu-compiler/units/compiler-0a62b70c/SKILL.md +45 -45
  393. package/skills/skills/lingshu-compiler/units/compiler-0ab24d00/SKILL.md +45 -45
  394. package/skills/skills/lingshu-compiler/units/compiler-0e093688/SKILL.md +45 -45
  395. package/skills/skills/lingshu-compiler/units/compiler-0e82b966/SKILL.md +45 -45
  396. package/skills/skills/lingshu-compiler/units/compiler-0ee9b9b9/SKILL.md +45 -45
  397. package/skills/skills/lingshu-compiler/units/compiler-0f3787e6/SKILL.md +45 -45
  398. package/skills/skills/lingshu-compiler/units/compiler-1028685f/SKILL.md +45 -45
  399. package/skills/skills/lingshu-compiler/units/compiler-11897630/SKILL.md +45 -45
  400. package/skills/skills/lingshu-compiler/units/compiler-16661b9b/SKILL.md +45 -45
  401. package/skills/skills/lingshu-compiler/units/compiler-1f722303/SKILL.md +45 -45
  402. package/skills/skills/lingshu-compiler/units/compiler-25be1262/SKILL.md +45 -45
  403. package/skills/skills/lingshu-compiler/units/compiler-2a76ba07/SKILL.md +45 -45
  404. package/skills/skills/lingshu-compiler/units/compiler-2dbea54a/SKILL.md +45 -45
  405. package/skills/skills/lingshu-compiler/units/compiler-2df52f16/SKILL.md +45 -45
  406. package/skills/skills/lingshu-compiler/units/compiler-2ec2c9d2/SKILL.md +45 -45
  407. package/skills/skills/lingshu-compiler/units/compiler-2f8c8f39/SKILL.md +45 -45
  408. package/skills/skills/lingshu-compiler/units/compiler-38378ac7/SKILL.md +45 -45
  409. package/skills/skills/lingshu-compiler/units/compiler-39457d2e/SKILL.md +45 -45
  410. package/skills/skills/lingshu-compiler/units/compiler-39fb5926/SKILL.md +45 -45
  411. package/skills/skills/lingshu-compiler/units/compiler-47f4fbfa/SKILL.md +45 -45
  412. package/skills/skills/lingshu-compiler/units/compiler-4a8cd1f1/SKILL.md +45 -45
  413. package/skills/skills/lingshu-compiler/units/compiler-4b230b6b/SKILL.md +45 -45
  414. package/skills/skills/lingshu-compiler/units/compiler-4cbbda95/SKILL.md +45 -45
  415. package/skills/skills/lingshu-compiler/units/compiler-4d5a68ff/SKILL.md +45 -45
  416. package/skills/skills/lingshu-compiler/units/compiler-56dc9bdd/SKILL.md +45 -45
  417. package/skills/skills/lingshu-compiler/units/compiler-57e76ebe/SKILL.md +45 -45
  418. package/skills/skills/lingshu-compiler/units/compiler-61ff016b/SKILL.md +45 -45
  419. package/skills/skills/lingshu-compiler/units/compiler-63eae588/SKILL.md +45 -45
  420. package/skills/skills/lingshu-compiler/units/compiler-64c4224b/SKILL.md +45 -45
  421. package/skills/skills/lingshu-compiler/units/compiler-66377955/SKILL.md +45 -45
  422. package/skills/skills/lingshu-compiler/units/compiler-674ab4f6/SKILL.md +45 -45
  423. package/skills/skills/lingshu-compiler/units/compiler-6af76fd6/SKILL.md +45 -45
  424. package/skills/skills/lingshu-compiler/units/compiler-6d979db2/SKILL.md +45 -45
  425. package/skills/skills/lingshu-compiler/units/compiler-6de326c0/SKILL.md +45 -45
  426. package/skills/skills/lingshu-compiler/units/compiler-6f1c0eea/SKILL.md +45 -45
  427. package/skills/skills/lingshu-compiler/units/compiler-724d6c9b/SKILL.md +45 -45
  428. package/skills/skills/lingshu-compiler/units/compiler-7690177f/SKILL.md +45 -45
  429. package/skills/skills/lingshu-compiler/units/compiler-7b229a7d/SKILL.md +45 -45
  430. package/skills/skills/lingshu-compiler/units/compiler-7f471b3a/SKILL.md +45 -45
  431. package/skills/skills/lingshu-compiler/units/compiler-815cad08/SKILL.md +45 -45
  432. package/skills/skills/lingshu-compiler/units/compiler-83c61634/SKILL.md +45 -45
  433. package/skills/skills/lingshu-compiler/units/compiler-8c798c81/SKILL.md +45 -45
  434. package/skills/skills/lingshu-compiler/units/compiler-8dd747ac/SKILL.md +45 -45
  435. package/skills/skills/lingshu-compiler/units/compiler-90324d0a/SKILL.md +45 -45
  436. package/skills/skills/lingshu-compiler/units/compiler-94b8d72d/SKILL.md +45 -45
  437. package/skills/skills/lingshu-compiler/units/compiler-94f12231/SKILL.md +45 -45
  438. package/skills/skills/lingshu-compiler/units/compiler-95937c16/SKILL.md +45 -45
  439. package/skills/skills/lingshu-compiler/units/compiler-98a5625b/SKILL.md +45 -45
  440. package/skills/skills/lingshu-compiler/units/compiler-98b4c42f/SKILL.md +45 -45
  441. package/skills/skills/lingshu-compiler/units/compiler-98e3894a/SKILL.md +45 -45
  442. package/skills/skills/lingshu-compiler/units/compiler-9bdfe4b8/SKILL.md +45 -45
  443. package/skills/skills/lingshu-compiler/units/compiler-9d9b4e83/SKILL.md +45 -45
  444. package/skills/skills/lingshu-compiler/units/compiler-9f7be5ad/SKILL.md +45 -45
  445. package/skills/skills/lingshu-compiler/units/compiler-a6daf076/SKILL.md +45 -45
  446. package/skills/skills/lingshu-compiler/units/compiler-a7995a0c/SKILL.md +45 -45
  447. package/skills/skills/lingshu-compiler/units/compiler-a8399248/SKILL.md +45 -45
  448. package/skills/skills/lingshu-compiler/units/compiler-aabbd099/SKILL.md +45 -45
  449. package/skills/skills/lingshu-compiler/units/compiler-afe169d8/SKILL.md +45 -45
  450. package/skills/skills/lingshu-compiler/units/compiler-b0678bda/SKILL.md +45 -45
  451. package/skills/skills/lingshu-compiler/units/compiler-b09bd196/SKILL.md +45 -45
  452. package/skills/skills/lingshu-compiler/units/compiler-b1396e23/SKILL.md +45 -45
  453. package/skills/skills/lingshu-compiler/units/compiler-b9b31ce0/SKILL.md +45 -45
  454. package/skills/skills/lingshu-compiler/units/compiler-c10264a7/SKILL.md +45 -45
  455. package/skills/skills/lingshu-compiler/units/compiler-cb1e8e4b/SKILL.md +45 -45
  456. package/skills/skills/lingshu-compiler/units/compiler-ccafd438/SKILL.md +45 -45
  457. package/skills/skills/lingshu-compiler/units/compiler-cda9c262/SKILL.md +45 -45
  458. package/skills/skills/lingshu-compiler/units/compiler-ce648068/SKILL.md +45 -45
  459. package/skills/skills/lingshu-compiler/units/compiler-cf5776a4/SKILL.md +45 -45
  460. package/skills/skills/lingshu-compiler/units/compiler-d974e5d3/SKILL.md +45 -45
  461. package/skills/skills/lingshu-compiler/units/compiler-e3979fd3/SKILL.md +45 -45
  462. package/skills/skills/lingshu-compiler/units/compiler-eb1cf2b5/SKILL.md +45 -45
  463. package/skills/skills/lingshu-compiler/units/compiler-ecb30d5b/SKILL.md +45 -45
  464. package/skills/skills/lingshu-compiler/units/compiler-f8c8b24b/SKILL.md +45 -45
  465. package/skills/skills/lingshu-compiler/units/compiler-f99fedbe/SKILL.md +45 -45
  466. package/skills/skills/lingshu-compiler/units/compiler-fa8ff5f7/SKILL.md +45 -45
  467. package/skills/skills/lingshu-compiler/units/compiler-fe1b058d/SKILL.md +45 -45
  468. package/skills/skills/lingshu-compiler/units/lex-chinese-program/SKILL.md +45 -45
  469. package/skills/skills/lingshu-compiler/units/lex-dao-de-jing/SKILL.md +45 -45
  470. package/skills/skills/lingshu-compiler/units/lex-nine-chapters/SKILL.md +45 -45
  471. package/skills/skills/lingshu-compiler/units/vm-arithmetic/SKILL.md +45 -45
  472. package/skills/skills/lingshu-compiler/units/vm-array-ops/SKILL.md +45 -45
  473. package/skills/skills/lingshu-compiler/units/vm-closure-call/SKILL.md +45 -45
  474. package/skills/skills/lingshu-compiler/units/vm-closure-create/SKILL.md +45 -45
  475. package/skills/skills/lingshu-compiler/units/vm-compare/SKILL.md +45 -45
  476. package/skills/skills/lingshu-compiler/units/vm-cond-jump/SKILL.md +45 -45
  477. package/skills/skills/lingshu-compiler/units/vm-cond-space/SKILL.md +45 -45
  478. package/skills/skills/lingshu-compiler/units/vm-exception/SKILL.md +45 -45
  479. package/skills/skills/lingshu-compiler/units/vm-func-call/SKILL.md +45 -45
  480. package/skills/skills/lingshu-compiler/units/vm-loop-run/SKILL.md +45 -45
  481. package/skills/skills/lingshu-compiler/units/vm-profiling/SKILL.md +45 -45
  482. package/skills/skills/lingshu-compiler/units/vm-refcount/SKILL.md +45 -45
  483. package/skills/skills/lingshu-compiler/units/vm-run-loop/SKILL.md +45 -45
  484. package/skills/skills/lingshu-compiler/units/vm-short-circuit/SKILL.md +45 -45
  485. package/skills/skills/lingshu-compiler/units/vm-stack-guard/SKILL.md +45 -45
  486. package/skills/skills/lingshu-compiler/units/vm-stack-ops/SKILL.md +45 -45
  487. package/skills/skills/lingshu-compiler/units/vm-trust-accum/SKILL.md +45 -45
  488. package/skills/skills/lingshu-graph/SKILL.md +63 -63
  489. package/skills/skills/lingshu-net/SKILL.md +48 -48
  490. package/skills/skills/lingshu-os/SKILL.md +64 -64
  491. package/skills/skills/lingshu-pylang/SKILL.md +71 -71
  492. package/src/bridge.ts +401 -401
  493. package/src/hooks.ts +38 -2
  494. package/src/lib/datapath.ts +326 -326
  495. package/src/lib/mdcg_client.ts +413 -413
  496. package/src/lib/mutual.ts +428 -428
  497. package/src/lib/prompt_safety.ts +62 -62
  498. package/src/lib/python_path.ts +71 -71
  499. package/src/lib/roleplay_web.ts +932 -932
  500. package/src/lib/token_store.ts +192 -192
  501. package/src/tools.ts +212 -212
  502. package/zcode/AGENTS.md +11 -3
  503. package/zcode/README.md +41 -41
  504. /package/docs/{mdcg → hive}//344/270/273/344/273/243/347/220/206/345/255/220/344/273/243/347/220/206/350/256/260/345/277/206/346/236/266/346/236/204/350/256/276/350/256/241.md" +0 -0
package/md_cg/sustain.py CHANGED
@@ -1,1139 +1,1139 @@
1
- # -*- coding: utf-8 -*-
2
- """md_cg · 持续性自维持(记忆 OS #4):常驻 / 心跳 / 自愈 / 会话续接
3
-
4
- 路线图「常驻服务:会话 / 心跳 / 自愈」的落地。回答三个问题:
5
-
6
- ① 别人怎么知道我还活着?—— **心跳戳**
7
- `<net_dir>/heartbeat.<name>.stamp`(原子写,含 ts / pid / uptime / task_running)。
8
- 分级判定对齐 Mutual Sustain Loop v1.1:正常 / 警告 / 失联,阈值按心跳间隔的
9
- 2.5× / 3.5×;**任务执行中阈值放宽**(working factor),避免长任务被误判死亡。
10
- 戳写在仓库外(默认 ~/.mdcg/sustain),不污染知识库,也不随仓库泄露。
11
-
12
- ② 进程被杀会不会留下坏状态?—— **自愈(diagnose → heal)**
13
- 诊断只读、不改动;修复动作幂等且逐条留审计:
14
- · 索引漂移 / 孤儿索引 → 重建索引(索引是派生物,可安全重建)
15
- · 索引增量分片积压 → 合并进快照
16
- · 陈旧临时文件 → 清理(被杀死的写者留下的唯一命名 tmp)
17
- · 日志半截行 → 补换行(否则下一条记录会粘在断行上)
18
- · 私有节点不可解 → **只报告不修**(缺密钥是权限事实,不是故障)
19
- 原则:修复只碰派生物(索引 / 临时文件 / 日志边界),**永不删节点**。
20
-
21
- ③ 重启后从哪继续?—— **会话水位(SessionLedger)**
22
- `<root>/_sessions.json` 记录每个会话的 (last_t, last_seq, events),
23
- 与 `sources.Ingestor` 的 `_sources.json`(源视角水位)互补;重启后
24
- `resume_point(session)` 直接给出续接点,不重复摄取、不丢事件。
25
-
26
- ④ 能力不会被饿死?—— **演化巡检(evolve)**
27
- 自我演化的三类动作(固化 `consolidate` / 重算重要性 `weights` / 去污染 `scrub`)
28
- 早已具备,缺的是**驱动源**:没人周期性问「现在有多少该固化 / 该重算的候选」。
29
- `evolution_candidates()` 以**索引快照**为口径做只读盘点(零读节点文件、零写盘、
30
- 确定性),并挂到常驻循环的 `_tick_evolve()` 上。纪律与 self-heal 一致且更严:
31
- · 巡检恒只读,`auto_evolve=False`(默认)时**只记账不动库**;
32
- · 自愈只放行**确定性且可回滚**的动作(重要性重算,有 rollback);
33
- · 依赖 LLM 的固化**永不自动跑**——巡检报出候选数,交人工另批。
34
-
35
- ⑤ 演进血缘有没有断?—— **派生溯源巡检(G8)**
36
- 新增节点在建链时把 `derived_from` 写进 frontmatter 并追加到 `<root>/_link.jsonl`。
37
- 可台账会丢、节点会被删,于是血缘会出现**悬空边**(子/父节点已不在库里)。
38
- `diagnose()` 每次都做只读盘点(零读节点文件:索引已带 `derived_from`),把悬空边
39
- 报为 `provenance_dangling`(severity=info、**无自动修复**)——关系事实的去留由人
40
- 处置,不给「自动删边」这种会篡改历史的动作。
41
-
42
- ⑥ 对端挂了谁来救?—— **互维闭环(P-T-110 最小投影 · #30)**
43
- 两个灵枢互为维生系统:`mutual_watch` 读对端心跳 → 失联则**幂等拉起**
44
- (pid 探活防误判 + 冷却防风暴)→ **验戳新鲜闭合**:拉起后必须轮询到
45
- 对端戳变新才算救活,否则如实报 `mutual_peer_unresponsive`——这正是
46
- mutual-sustain-loop v1.1 §7 的 W4 部署教训(「拉起后应验证对端戳新鲜度,
47
- 当时缺该校验」)的机制化:不假装成功。`mutual_status` 做双亡检测:
48
- 自己也失联时互维本身不可信 → 显式上报外部告警语义,绝不静默。
49
-
50
- 零第三方依赖。
51
- """
52
- from __future__ import annotations
53
-
54
- import json
55
- import os
56
- import threading
57
- import time
58
-
59
- from . import crypto
60
- from .fsutil import append_jsonl, atomic_write, ends_mid_line
61
- from .mdcg import LAYERS
62
-
63
- STAMP_VERSION = 1
64
- SUSTAIN_LOG = "_sustain.jsonl"
65
- LEDGER_FILE = "_sessions.json"
66
-
67
- DEFAULT_BEAT_INTERVAL = 600.0 # 心跳间隔 10min(对齐 mutual-sustain-loop)
68
- DEFAULT_HEAL_INTERVAL = 300.0 # 自愈巡检 5min
69
- DEFAULT_SCRUB_INTERVAL = 3600.0 # 记忆自净(抽查/去污染/校准)1h
70
- DEFAULT_EVOLVE_INTERVAL = 7200.0 # 演化巡检(固化/重要性候选盘点)2h;只读
71
- DEFAULT_TIDY_INTERVAL = 21600.0 # 整理巡检(contextual 同构组聚合)6h
72
- DEFAULT_WARN_FACTOR = 2.5 # 2.5× 心跳间隔 → 警告
73
- DEFAULT_DEAD_FACTOR = 3.5 # 3.5× → 失联
74
- DEFAULT_WORKING_FACTOR = 2.0 # 任务执行中阈值 ×2
75
- STALE_TEMP_AGE = 3600.0 # 临时文件超过 1h 视为陈旧
76
- _POLL = 0.2 # 循环轮询步长(常驻进程 CPU 可忽略)
77
-
78
-
79
- # --------------------------------------------------------------------------
80
- # 心跳
81
- # --------------------------------------------------------------------------
82
-
83
- # 生效条件:d 为真值时返回 d,d 为 None/空串时回落 MDCG_SUSTAIN_DIR,该环境变量也未设或为空串时返回 os.path.join(os.path.expanduser("~"), ".mdcg", "sustain");
84
- def net_dir(d: str = None) -> str:
85
- """心跳戳目录:显式 → MDCG_SUSTAIN_DIR → ~/.mdcg/sustain(仓库外)。"""
86
- return (d or os.environ.get("MDCG_SUSTAIN_DIR")
87
- or os.path.join(os.path.expanduser("~"), ".mdcg", "sustain"))
88
-
89
-
90
- # 生效条件:给定必填 name,返回 net_dir(d) 下 heartbeat.<name>.stamp 的拼接路径。
91
- def stamp_path(name: str, d: str = None) -> str:
92
- return os.path.join(net_dir(d), f"heartbeat.{name}.stamp")
93
-
94
-
95
- # 生效条件:name 必填,组装含模块常量 STAMP_VERSION 与 task_running 的戳记录、原子写入 stamp_path(name,d) 后返回该 rec。
96
- def write_stamp(name: str, d: str = None, **extra) -> dict:
97
- """写一次心跳(原子替换)。extra 会并入戳内容(None 值丢弃)。"""
98
- rec = {"v": STAMP_VERSION, "name": name, "ts": time.time(),
99
- "pid": os.getpid(),
100
- "iso": time.strftime("%Y-%m-%dT%H:%M:%S"),
101
- "task_running": bool(extra.pop("task_running", False))}
102
- for k, v in extra.items():
103
- if v is not None:
104
- rec[k] = v
105
- atomic_write(stamp_path(name, d),
106
- json.dumps(rec, ensure_ascii=False, indent=1))
107
- return rec
108
-
109
-
110
- # 生效条件:stamp_path(name, d) 所得路径上 os.path.exists 为真且 json.load 结果为 dict 时,返回该 rec 并附加 age=max(0.0, time.time() - float(rec.get("ts") or 0));该路径不可读、json.load 抛 ValueError/OSError 或 rec 非 dict 时返回 None;
111
- def read_stamp(name: str, d: str = None):
112
- """读心跳戳并附 age(秒);不存在 / 损坏 → None。"""
113
- p = stamp_path(name, d)
114
- if not os.path.exists(p):
115
- return None
116
- try:
117
- with open(p, encoding="utf-8") as f:
118
- rec = json.load(f)
119
- except (ValueError, OSError):
120
- return None
121
- if not isinstance(rec, dict):
122
- return None
123
- rec["age"] = max(0.0, time.time() - float(rec.get("ts") or 0))
124
- return rec
125
-
126
-
127
- # 生效条件:对 stamp_path(name,d) 执行 os.remove,OSError 被静默忽略,无返回值。
128
- def clear_stamp(name: str, d: str = None):
129
- try:
130
- os.remove(stamp_path(name, d))
131
- except OSError:
132
- pass
133
-
134
-
135
- # 生效条件:age 为 None 返回 'absent';否则以 factor=working if task_running else 1.0 比较 age 与 interval*warn*factor、interval*dead*factor,分别返回 'ok'/'warning'/'dead'。
136
- def judge(age, *, interval: float = DEFAULT_BEAT_INTERVAL,
137
- task_running: bool = False, warn: float = DEFAULT_WARN_FACTOR,
138
- dead: float = DEFAULT_DEAD_FACTOR,
139
- working: float = DEFAULT_WORKING_FACTOR) -> str:
140
- """按心跳年龄分级:ok / warning / dead / absent。
141
-
142
- task_running=True 时阈值整体放宽 working 倍——长任务期间不写心跳是正常的,
143
- 若不放宽会把「正在干活」误判成「已死」。
144
- """
145
- if age is None:
146
- return "absent"
147
- factor = working if task_running else 1.0
148
- if age <= interval * warn * factor:
149
- return "ok"
150
- if age <= interval * dead * factor:
151
- return "warning"
152
- return "dead"
153
-
154
-
155
- # 生效条件:net_dir(d) 可列为目录时遍历 heartbeat.*.stamp 并返回含 age 与 state 的记录列表,否则返回 []。
156
- def peers(d: str = None):
157
- """列出本机所有心跳戳(含状态)。"""
158
- base = net_dir(d)
159
- if not os.path.isdir(base):
160
- return []
161
- out = []
162
- for fn in sorted(os.listdir(base)):
163
- if not (fn.startswith("heartbeat.") and fn.endswith(".stamp")):
164
- continue
165
- rec = read_stamp(fn[len("heartbeat."):-len(".stamp")], base)
166
- if rec:
167
- rec["state"] = judge(rec["age"],
168
- task_running=bool(rec.get("task_running")))
169
- out.append(rec)
170
- return out
171
-
172
-
173
- # --------------------------------------------------------------------------
174
- # 互维闭环(P-T-110 最小投影 · #30)
175
- # --------------------------------------------------------------------------
176
-
177
- DEFAULT_RESTART_COOLDOWN = 300.0 # 同一对端两次拉起的最小间隔(防风暴)
178
- DEFAULT_FRESH_TIMEOUT = 60.0 # 拉起后等待对端戳变新鲜的窗口
179
-
180
-
181
- # 生效条件:pid 可转 int 且 >0 时按探活(os.name=='nt' 用 OpenProcess+WaitForSingleObject,否则 os.kill(pid,0))返回 True,转 int 失败或 pid<=0 或探活失败返回 False。
182
- def pid_alive(pid) -> bool:
183
- """进程探活(零第三方依赖):Windows=OpenProcess+WaitForSingleObject;
184
- posix=os.kill(pid,0)。pid 无效 / 已退出 / 权限外 → False(诚实)。"""
185
- try:
186
- pid = int(pid)
187
- except (TypeError, ValueError):
188
- return False
189
- if pid <= 0:
190
- return False
191
- if os.name == "nt":
192
- import ctypes
193
- SYNCHRONIZE = 0x00100000
194
- WAIT_TIMEOUT = 0x00000102
195
- k32 = ctypes.windll.kernel32
196
- h = k32.OpenProcess(SYNCHRONIZE, False, pid)
197
- if not h:
198
- return False
199
- try:
200
- return k32.WaitForSingleObject(h, 0) == WAIT_TIMEOUT
201
- finally:
202
- k32.CloseHandle(h)
203
- try:
204
- os.kill(pid, 0)
205
- return True
206
- except OSError:
207
- return False
208
-
209
-
210
- # 生效条件:cmd 为 argv 列表时以 stdout/stderr=DEVNULL、不经 shell 的 subprocess.Popen 拉起(nt 附加 DETACHED|NEW_GROUP creationflags),返回该 Popen 对象。
211
- def _default_spawner(cmd):
212
- """分离式拉起:argv 列表、不经 shell、输出弃置(Windows 完全脱离父控制台)。"""
213
- import subprocess
214
- kw = {}
215
- if os.name == "nt":
216
- kw["creationflags"] = (0x00000008 | 0x00000200) # DETACHED|NEW_GROUP
217
- return subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
218
- stderr=subprocess.DEVNULL, **kw)
219
-
220
-
221
- # 生效条件:root 与 peer 必填,扫描 root/SUSTAIN_LOG(模块常量)返回 op='mutual'、action='restart' 且 detail 以 peer 开头的记录中最大 t,无匹配返回 0.0。
222
- def _last_mutual_restart(root: str, peer: str) -> float:
223
- """_sustain.jsonl 里该对端最近一次互维拉起时间(无 → 0.0,审计即状态)。"""
224
- last = 0.0
225
- try:
226
- with open(os.path.join(root, SUSTAIN_LOG), encoding="utf-8") as f:
227
- for line in f:
228
- try:
229
- r = json.loads(line)
230
- except ValueError:
231
- continue
232
- if (r.get("op") == "mutual" and r.get("action") == "restart"
233
- and str(r.get("detail") or "").startswith(str(peer))):
234
- last = max(last, float(r.get("t") or 0.0))
235
- except OSError:
236
- pass
237
- return last
238
-
239
-
240
- # 生效条件:cg 与 peer 必填,读对端戳判状态:ok/warning 直接返回 action='none';dead/absent 时依次按 pid_alive、restart_cooldown、restart_cmd 有无走 alive_but_stale/restart_cooldown/dead_unhandled/拉起并验戳,返回 out。
241
- def mutual_watch(cg, peer: str, *, restart_cmd=None, d: str = None,
242
- interval: float = DEFAULT_BEAT_INTERVAL,
243
- restart_cooldown: float = DEFAULT_RESTART_COOLDOWN,
244
- fresh_timeout: float = DEFAULT_FRESH_TIMEOUT, poll: float = 0.5,
245
- spawner=None) -> dict:
246
- """互维守护(单侧一次检查):读对端心跳 → 失联则幂等拉起 → 验戳闭合。
247
-
248
- 判定序(P-T-110 语义,全链路审计进 _sustain.jsonl):
249
- ok/warning → 不动作;
250
- dead/absent:
251
- ① 误判防护:戳里 pid 仍存活 → `alive_but_stale`(长任务未标
252
- task_running?)——**不拉起**,不杀活进程;
253
- ② 冷却:距上次拉起 < restart_cooldown → `restart_cooldown`;
254
- ③ 无 restart_cmd → `dead_unhandled`(ok=False,诚实上报没手段,
255
- 不假装能救);
256
- ④ 拉起(spawner 或分离式 Popen)→ **验戳新鲜闭合**(W4 补丁):
257
- 轮询对端戳 ts 超过拉起前值才算 `recovered=True`;超时 →
258
- `recovered=False` + `mutual_peer_unresponsive`——对端拉起后
259
- 互维未激活(v1.1 §7 的 W4 场景)会被显式暴露,不假装成功。
260
- """
261
- rec = read_stamp(peer, d)
262
- state = (judge(rec["age"], interval=interval,
263
- task_running=bool(rec.get("task_running")))
264
- if rec else "absent")
265
- out = {"ok": True, "peer": peer, "state": state, "t": time.time(),
266
- "action": "none", "recovered": None}
267
- if state in ("ok", "warning"):
268
- return out
269
- if rec and pid_alive(rec.get("pid")):
270
- out.update(action="alive_but_stale",
271
- note="对端进程存活但戳陈旧(长任务未标 task_running?)——不拉起")
272
- _audit(cg.root, "mutual", "alive_but_stale", peer)
273
- return out
274
- last = _last_mutual_restart(cg.root, peer)
275
- if last and (time.time() - last) < restart_cooldown:
276
- out.update(action="restart_cooldown",
277
- note="距上次拉起 %.1fs,冷却中" % (time.time() - last))
278
- return out
279
- if not restart_cmd:
280
- out.update(ok=False, action="dead_unhandled",
281
- note="对端失联且未提供 restart_cmd——诚实上报,不假装能救")
282
- _audit(cg.root, "mutual", "dead_unhandled", peer)
283
- return out
284
- before_ts = float((rec or {}).get("ts") or 0.0)
285
- spawn = spawner or _default_spawner
286
- try:
287
- spawn(restart_cmd)
288
- except Exception as e: # noqa: BLE001
289
- out.update(ok=False, action="restart_error",
290
- error="%s: %s" % (type(e).__name__, e))
291
- _audit(cg.root, "mutual", "restart_error", peer)
292
- return out
293
- deadline = time.time() + fresh_timeout
294
- while time.time() < deadline:
295
- r2 = read_stamp(peer, d)
296
- if r2 and float(r2.get("ts") or 0.0) > before_ts:
297
- out.update(action="restarted", recovered=True, fresh_ts=r2.get("ts"))
298
- _audit(cg.root, "mutual", "restart", peer)
299
- return out
300
- time.sleep(poll)
301
- out.update(ok=False, action="restarted", recovered=False,
302
- note=("拉起命令已执行但对端戳未更新(互维未激活?)——"
303
- "mutual_peer_unresponsive,不假装成功"))
304
- _audit(cg.root, "mutual", "restart_unverified", peer)
305
- return out
306
-
307
-
308
- # 生效条件:cg 与 peer 必填,分别按 name 与 peer 读戳判状态,仅当自身与对端状态均属 ('dead','absent') 时返回 ok=False、both_dead=True。
309
- def mutual_status(cg, peer: str, *, d: str = None, name: str = "md_cg",
310
- interval: float = DEFAULT_BEAT_INTERVAL) -> dict:
311
- """互维状态 + 双亡检测:自己也失联时互维不可信 → 显式外部告警语义。"""
312
- mine, theirs = read_stamp(name, d), read_stamp(peer, d)
313
- my_state = (judge(mine["age"], interval=interval,
314
- task_running=bool(mine.get("task_running")))
315
- if mine else "absent")
316
- peer_state = (judge(theirs["age"], interval=interval,
317
- task_running=bool(theirs.get("task_running")))
318
- if theirs else "absent")
319
- dead = ("dead", "absent")
320
- both_dead = my_state in dead and peer_state in dead
321
- return {"ok": not both_dead, "self": my_state, "peer": peer_state,
322
- "both_dead": both_dead,
323
- "note": ("双向同时失联:互维本身已不可信,须外部告警(P-T-110 风险表)"
324
- if both_dead else "互维链路可用")}
325
-
326
-
327
- # --------------------------------------------------------------------------
328
- # 诊断(只读)
329
- # --------------------------------------------------------------------------
330
-
331
- # 生效条件:root 必填,对模块常量 LAYERS 各层 os.walk 统计以 .md 结尾的文件名个数并返回 n(不解析正文)。
332
- def _count_node_files(root: str) -> int:
333
- """轻量统计磁盘节点数(只数文件名,不解析正文)。"""
334
- n = 0
335
- for layer in LAYERS:
336
- for _dp, _dirs, files in os.walk(os.path.join(root, layer)):
337
- n += sum(1 for f in files if f.endswith(".md"))
338
- return n
339
-
340
-
341
- # 生效条件:root 与 older_than 必填,返回 root 及其 LAYERS 各层下以 '.' 开头、名含 '.tmp-' 且 mtime 早于 now-older_than 的路径列表。
342
- def _list_stale_temps(root: str, older_than: float):
343
- now, out = time.time(), []
344
- bases = [root] + [os.path.join(root, l) for l in LAYERS]
345
- for base in bases:
346
- if not os.path.isdir(base):
347
- continue
348
- for name in os.listdir(base):
349
- if ".tmp-" not in name or not name.startswith("."):
350
- continue
351
- p = os.path.join(base, name)
352
- try:
353
- if now - os.path.getmtime(p) > older_than:
354
- out.append(p)
355
- except OSError:
356
- pass
357
- return out
358
-
359
-
360
- # 生效条件:root 必填,返回 root 顶层以 .jsonl/.log 结尾且 ends_mid_line 为真的路径列表;root 非目录返回 []。
361
- def _half_line_logs(root: str):
362
- """顶层行式日志里末尾不是换行的(写者被杀留下的半截记录)。"""
363
- out = []
364
- if not os.path.isdir(root):
365
- return out
366
- for name in sorted(os.listdir(root)):
367
- if not (name.endswith(".jsonl") or name.endswith(".log")):
368
- continue
369
- p = os.path.join(root, name)
370
- if os.path.isfile(p) and ends_mid_line(p):
371
- out.append(p)
372
- return out
373
-
374
-
375
- # 生效条件:root/_index_log 可列为目录时返回其中 .log 结尾的文件名列表,否则返回 []。
376
- def _index_log_shards(root: str):
377
- d = os.path.join(root, "_index_log")
378
- if not os.path.isdir(d):
379
- return []
380
- return [f for f in os.listdir(d) if f.endswith(".log")]
381
-
382
-
383
- # 生效条件:cg 具备 crypto_status 且其返回 unlocked 为假时,返回 cg.index['nodes'] 中 sensitivity 属 crypto.ENCRYPTED_LEVELS 的条目数;属性缺失、调用异常或已解锁时返回 0。
384
- def _locked_nodes(cg) -> int:
385
- """加密引擎在「未解锁」状态下被隔离的私有节点数(缺密钥 = 权限事实)。"""
386
- if not hasattr(cg, "crypto_status"):
387
- return 0
388
- try:
389
- st = cg.crypto_status() or {}
390
- except Exception:
391
- return 0
392
- if st.get("unlocked"):
393
- return 0
394
- nodes = (getattr(cg, "index", {}) or {}).get("nodes") or {}
395
- return sum(1 for e in nodes.values()
396
- if (e.get("sensitivity") or "") in crypto.ENCRYPTED_LEVELS)
397
-
398
-
399
- # --------------------------------------------------------------------------
400
- # 演化巡检(G7:给「固化 / 重要性」能力装上驱动源)
401
- # --------------------------------------------------------------------------
402
-
403
- #: 演化巡检项 → 对应的写入动作(`scrub` 已有独立 tick,不并入此项)
404
- EVOLVE_FIXES = {"ccg_backlog": "consolidate_run",
405
- "importance_drift": "importance"}
406
-
407
-
408
- # 生效条件:nodes 与 top 必填,遍历索引条目统计缺 verification_basis 或 has_neg_conditions 的候补数 n、no_basis、no_neg 并取最多 top 个 sample,返回含 proxy=True 与 EVOLVE_FIXES['ccg_backlog'] 的字典。
409
- def _ccg_backlog(nodes: dict, top: int) -> dict:
410
- """固化候补量(**索引代理指标**:零读节点文件、确定性、O(N))。
411
-
412
- 真正的固化闸门在 `consolidate`(四要素 + 验证基底 + 白箱 replay,需读正文与 LLM)。
413
- 巡检只要**驱动信号**:索引里的 `verification_basis` / `has_neg_conditions` 已能
414
- 区分「有/无验证基底」「有/无负条件」,足够回答「有没有货等着固化」。
415
-
416
- 代理指标 ≠ 判定结论:报出的是**候补量**,不是「这些节点确实该固化」。
417
- """
418
- n = no_basis = no_neg = 0
419
- sample = []
420
- for nid, e in nodes.items():
421
- mb = not e.get("verification_basis")
422
- mn = not e.get("has_neg_conditions")
423
- no_basis += 1 if mb else 0
424
- no_neg += 1 if mn else 0
425
- if mb or mn:
426
- n += 1
427
- if len(sample) < top:
428
- sample.append(nid)
429
- return {"n": n, "no_basis": no_basis, "no_neg": no_neg, "sample": sample,
430
- "proxy": True, "fix": EVOLVE_FIXES["ccg_backlog"]}
431
-
432
-
433
- # 生效条件:cg 必填,取 cg.index['nodes'](layer 非空时按 layer 过滤),经 weights.recalc(apply=False) 与 _ccg_backlog 汇总,返回 ok=True、readonly=True、dry_run=True 的候选盘点字典。
434
- def evolution_candidates(cg, *, layer: str = None, top: int = 8,
435
- min_delta: float = None) -> dict:
436
- """演化候选盘点(G7):**只读、零读节点文件、确定性、不写盘**。
437
-
438
- 回答「现在有多少该固化 / 该重算重要性的候选」,供常驻巡检与人工决策。
439
- 与 `diagnose()`(故障体检)刻意分开:这里盘的是**演化工作量**,不是故障——
440
- 候补多不代表库有毛病,故 `ok` 恒 True、severity 恒 info。
441
-
442
- `importance_drift` 复用 `weights.recalc(apply=False)`(纯数学,不读文件);
443
- `ccg_backlog` 用索引代理指标。二者都不碰节点内容。
444
- """
445
- nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
446
- if layer:
447
- nodes = {k: v for k, v in nodes.items() if v.get("layer") == layer}
448
- from . import weights
449
- md = weights.APPLY_DELTA if min_delta is None else float(min_delta)
450
- imp = weights.recalc(cg, layer=layer, apply=False, min_delta=md,
451
- dry_run_samples=top)
452
- cb = _ccg_backlog(nodes, top)
453
- idr = {"n": imp["changed"], "scanned": imp["nodes_scanned"],
454
- "min_delta": imp["min_delta"],
455
- "sample": [s["node_id"] for s in imp["samples"]],
456
- "fix": EVOLVE_FIXES["importance_drift"]}
457
- return {"ok": True, "action": "evolve_check", "op": "sustain",
458
- "root": cg.root, "t": time.time(), "readonly": True, "dry_run": True,
459
- "nodes": len(nodes), "top": top, "layer": layer,
460
- "ccg_backlog": cb, "importance_drift": idr,
461
- "candidates": cb["n"] + idr["n"],
462
- "by_fix": {EVOLVE_FIXES["ccg_backlog"]: cb["n"],
463
- EVOLVE_FIXES["importance_drift"]: idr["n"]},
464
- "note": ("只读盘点:未写盘、未改任何节点;固化需 LLM(交人工/另批),"
465
- "重要性重算为确定性动作(有 rollback)")}
466
-
467
-
468
- # 生效条件:给定 cg 后只读汇总(nodes 取自 cg.index、disk 计数、refindex.check_refs),stale_temp_age 传入 _list_stale_temps;check_heartbeat / check_evolution(evolve_top) / check_provenance(provenance_top) 为真时分别追加对应 issue,返回 ok = 无 severity=="warning" 的 issue 连同 stats。
469
- def diagnose(cg, *, name: str = "md_cg", stale_temp_age: float = STALE_TEMP_AGE,
470
- check_heartbeat: bool = True, check_evolution: bool = True,
471
- evolve_top: int = 5, check_provenance: bool = True,
472
- provenance_top: int = 5) -> dict:
473
- """只读体检:返回 issues(带 fix 名)与 stats,不改动任何文件。"""
474
- root = cg.root
475
- issues = []
476
- nodes = (getattr(cg, "index", {}) or {}).get("nodes") or {}
477
- disk = _count_node_files(root)
478
-
479
- if len(nodes) != disk:
480
- issues.append({"code": "index_drift", "severity": "warning",
481
- "detail": f"索引 {len(nodes)} ≠ 磁盘 {disk}",
482
- "fix": "rebuild_index"})
483
- orphans = [nid for nid, e in nodes.items()
484
- if e.get("path")
485
- and not os.path.exists(os.path.join(root, e["path"]))]
486
- if orphans:
487
- issues.append({"code": "index_orphan", "severity": "warning",
488
- "detail": f"{len(orphans)} 条索引指向不存在的文件",
489
- "sample": orphans[:5], "fix": "rebuild_index"})
490
-
491
- shards = _index_log_shards(root)
492
- if shards:
493
- issues.append({"code": "index_log_backlog", "severity": "info",
494
- "detail": f"{len(shards)} 个索引增量分片未合并",
495
- "fix": "flush_index"})
496
-
497
- temps = _list_stale_temps(root, stale_temp_age)
498
- if temps:
499
- issues.append({"code": "stale_temps", "severity": "info",
500
- "detail": f"{len(temps)} 个陈旧临时文件",
501
- "sample": [os.path.basename(p) for p in temps[:5]],
502
- "fix": "sweep_temps"})
503
-
504
- half = _half_line_logs(root)
505
- if half:
506
- issues.append({"code": "half_line_logs", "severity": "warning",
507
- "detail": f"{len(half)} 个日志末尾半截行",
508
- "sample": [os.path.basename(p) for p in half[:5]],
509
- "fix": "seal_half_lines"})
510
-
511
- locked = _locked_nodes(cg)
512
- if locked:
513
- issues.append({"code": "locked_nodes", "severity": "info",
514
- "detail": f"{locked} 个私有节点密文不可解(缺密钥)",
515
- "fix": None}) # 权限事实,不自愈
516
-
517
- # ref 漂移 / 悬空(R3):索引是派生物,源变了就报 stale,源没了就报 dangling。
518
- # 只读、不抛;修复动作是重跑 index_code / index_doc(rebuild_refs)。
519
- from . import refindex
520
- refs = refindex.check_refs(cg, ledger=refindex.Ledger(root))
521
- if refs["stale"]:
522
- issues.append({"code": "ref_stale", "severity": "warning",
523
- "detail": f"{len(refs['stale'])} 个 ref 漂移(源文件已改动)",
524
- "sample": [r.get("path") for r in refs["stale"][:5]],
525
- "fix": "rebuild_refs"})
526
- if refs["dangling"]:
527
- issues.append({"code": "ref_dangling", "severity": "warning",
528
- "detail": f"{len(refs['dangling'])} 个 ref 悬空(源文件已删除)",
529
- "sample": [r.get("path") for r in refs["dangling"][:5]],
530
- "fix": "rebuild_refs"})
531
- if refs.get("truncated"):
532
- issues.append({"code": "ref_check_truncated", "severity": "info",
533
- "detail": f"ref 巡检只覆盖前 {refs['max_nodes']} 个节点,结果不完整",
534
- "fix": None}) # 覆盖缺口,显式说出来而非静默
535
-
536
- if check_heartbeat:
537
- st = read_stamp(name)
538
- state = (judge(st["age"], task_running=bool(st.get("task_running")))
539
- if st else "absent")
540
- if state != "ok":
541
- issues.append({"code": "heartbeat_" + state, "severity": "info",
542
- "detail": f"本机心跳状态:{state}", "fix": "beat"})
543
-
544
- # ---- 演化巡检(G7):盘的是「该做多少事」,不是「库有毛病」,
545
- # 故 severity 恒 info(不影响 ok),且全程只读、零读节点文件。
546
- evolve = None
547
- if check_evolution:
548
- evolve = evolution_candidates(cg, top=evolve_top)
549
- cb, idr = evolve["ccg_backlog"], evolve["importance_drift"]
550
- if cb["n"]:
551
- issues.append({"code": "ccg_backlog", "severity": "info",
552
- "detail": (f"{cb['n']} 个固化候补"
553
- f"(索引代理:无验证基底 {cb['no_basis']}"
554
- f" / 无负条件 {cb['no_neg']})"),
555
- "sample": cb["sample"], "fix": cb["fix"],
556
- "proxy": True})
557
- if idr["n"]:
558
- issues.append({"code": "importance_drift", "severity": "info",
559
- "detail": (f"{idr['n']} 个节点结构重要性偏离 "
560
- f"≥{idr['min_delta']}(可重算)"),
561
- "sample": idr["sample"], "fix": idr["fix"]})
562
-
563
- # ---- 派生溯源巡检(G8):只读检出悬空派生边(端点已不在索引内)。
564
- # **无自动修复**:删边等于篡改演进血缘,只报告、由人处置;
565
- # 故 severity 恒 info(不影响 ok、不触发自愈),零读节点文件。
566
- prov = None
567
- if check_provenance:
568
- from . import provenance as _pv
569
- prov = _pv.check(cg, limit=provenance_top)
570
- if prov["dangling_count"]:
571
- issues.append({"code": "provenance_dangling", "severity": "info",
572
- "detail": (f"{prov['dangling_count']} 条派生边悬空"
573
- f"({prov['edges']} 条边中,端点不在索引内)"),
574
- "sample": [f"{r['child']}->{r['parent']}"
575
- for r in prov["dangling"]],
576
- "fix": None}) # 关系事实:只检出,不自动删边
577
-
578
- return {"ok": not any(i["severity"] == "warning" for i in issues),
579
- "root": root, "issues": issues, "t": time.time(),
580
- "evolve": evolve, "provenance": prov,
581
- "stats": {"nodes_indexed": len(nodes), "nodes_on_disk": disk,
582
- "index_log_shards": len(shards), "stale_temps": len(temps),
583
- "half_line_logs": len(half), "locked_nodes": locked,
584
- "ref_checked": refs["checked"],
585
- "ref_stale": len(refs["stale"]),
586
- "ref_dangling": len(refs["dangling"]),
587
- "provenance_edges": (prov["edges"] if prov else 0),
588
- "provenance_dangling":
589
- (prov["dangling_count"] if prov else 0),
590
- "evolve_candidates":
591
- (evolve["candidates"] if evolve else 0)}}
592
-
593
-
594
- # --------------------------------------------------------------------------
595
- # 自愈
596
- # --------------------------------------------------------------------------
597
-
598
- # 生效条件:path 必填,以 'ab' 模式向该路径追加写入单个换行 b'\n',无返回值。
599
- def _seal_half_line(path: str):
600
- with open(path, "ab") as f:
601
- f.write(b"\n")
602
-
603
-
604
- # 生效条件:root/op/action 必填,向 root/SUSTAIN_LOG(模块常量)追加一行含 t/op/action/detail(截断 200)/pid 的 JSONL,无返回值。
605
- def _audit(root: str, op: str, action: str, detail: str = ""):
606
- append_jsonl(os.path.join(root, SUSTAIN_LOG),
607
- {"t": time.time(), "op": op, "action": action,
608
- "detail": str(detail)[:200], "pid": os.getpid()})
609
-
610
-
611
- # 生效条件:按 diagnose(cg, name=name, stale_temp_age=stale_temp_age) 的 issues code 集合分派——命中 index_drift/index_orphan 重建索引、ref_stale/ref_dangling 按 ref 重建源索引、index_log_backlog 合并索引分片、stale_temps 清理陈旧临时文件、half_line_logs 修补半截日志;ccg_backlog 仅 allow_evolve=True 且 reflect_fn 非 None 时才 consolidate(否则记 needs_llm),importance_drift 仅 allow_evolve=True 时才重算重要性(否则记 evolve_disabled);dry_run=True 时各动作只记入 actions 不落盘,返回含 after["ok"]、dry_run、actions、before/after 的 stats 与 t 的 dict;
612
- def heal(cg, *, name: str = "md_cg", dry_run: bool = False,
613
- stale_temp_age: float = STALE_TEMP_AGE,
614
- allow_evolve: bool = False, reflect_fn=None, verify_fn=None) -> dict:
615
- """按诊断结果修复派生物。dry_run=True 时只列动作、不落盘。
616
-
617
- 演化类动作(G7)默认**不动**,须显式 `allow_evolve=True` 才放行,且只放行
618
- **确定性**动作(重要性重算,有 rollback);依赖 LLM 的固化永不自动跑。
619
- """
620
- before = diagnose(cg, name=name, stale_temp_age=stale_temp_age)
621
- codes = {i["code"] for i in before["issues"]}
622
- root = cg.root
623
- actions = []
624
-
625
- # 生效条件:闭包 dry_run 为真时向 actions 追加 {"code": code, "detail": detail, "applied": False} 并返回;否则调用 fn(),成功追加 applied=True/ok=True,抛异常时追加 applied=True/ok=False 与 error,最后执行 _audit(root, "heal", code, detail);
626
- def act(code: str, detail: str, fn):
627
- if dry_run:
628
- actions.append({"code": code, "detail": detail, "applied": False})
629
- return
630
- try:
631
- fn()
632
- actions.append({"code": code, "detail": detail,
633
- "applied": True, "ok": True})
634
- except Exception as e: # 自愈失败不能拖垮进程
635
- actions.append({"code": code, "detail": detail, "applied": True,
636
- "ok": False, "error": f"{type(e).__name__}: {e}"})
637
- _audit(root, "heal", code, detail)
638
-
639
- if "index_drift" in codes or "index_orphan" in codes:
640
- act("rebuild_index", "重建索引(漂移 / 孤儿)", cg.rebuild_index)
641
- if "ref_stale" in codes or "ref_dangling" in codes:
642
- from . import refindex as _ri
643
- _led = _ri.Ledger(root)
644
- act("rebuild_refs", "按 ref 重建源索引(修复漂移;悬空需人工处置)",
645
- lambda: _ri.rebuild(cg, ledger=_led))
646
- if "index_log_backlog" in codes:
647
- act("flush_index", "合并索引增量分片", cg.flush)
648
- if "stale_temps" in codes:
649
- paths = _list_stale_temps(root, stale_temp_age)
650
-
651
- # 生效条件:对闭包变量 paths 中每个路径尝试 os.remove(p),单个路径的 OSError 被吞掉后继续处理后续路径;
652
- def _sweep():
653
- for p in paths:
654
- try:
655
- os.remove(p)
656
- except OSError:
657
- pass
658
- act("sweep_temps", f"清理 {len(paths)} 个陈旧临时文件", _sweep)
659
- if "half_line_logs" in codes:
660
- paths = _half_line_logs(root)
661
- act("seal_half_lines", f"修补 {len(paths)} 个半截日志行",
662
- lambda: [_seal_half_line(p) for p in paths])
663
-
664
- # ---- 演化类修复(G7):默认关闭,且只放行确定性动作 ----
665
- if "ccg_backlog" in codes:
666
- det = next((i for i in before["issues"] if i["code"] == "ccg_backlog"), {})
667
- detail = f"{det.get('detail', '固化候补')};固化需 LLM 反思/验证"
668
- if allow_evolve and reflect_fn is not None:
669
- from . import consolidate as _cd
670
-
671
- # 生效条件:调用时返回 _cd.consolidate(cg.root, apply=True, reflect_fn=reflect_fn, verify_fn=verify_fn);
672
- def _consolidate():
673
- return _cd.consolidate(cg.root, apply=True,
674
- reflect_fn=reflect_fn,
675
- verify_fn=verify_fn)
676
- act("consolidate_run", detail, _consolidate)
677
- else:
678
- actions.append({"code": "consolidate_run", "detail": detail,
679
- "applied": False, "reason": "needs_llm"})
680
- if "importance_drift" in codes:
681
- if allow_evolve:
682
- from . import weights
683
-
684
- # 生效条件:调用时返回 weights.recalc(cg, apply=True, actor="sustain_evolve");
685
- def _importance():
686
- return weights.recalc(cg, apply=True, actor="sustain_evolve")
687
- act("importance", "重算结构重要性(确定性;可 rollback)", _importance)
688
- else:
689
- actions.append({"code": "importance",
690
- "detail": "重算结构重要性(确定性动作)",
691
- "applied": False, "reason": "evolve_disabled"})
692
-
693
- after = (before if dry_run
694
- else diagnose(cg, name=name, stale_temp_age=stale_temp_age))
695
- return {"ok": after["ok"], "dry_run": dry_run, "actions": actions,
696
- "before": before["stats"], "after": after["stats"],
697
- "t": time.time()}
698
-
699
-
700
- # --------------------------------------------------------------------------
701
- # 会话水位(重启续接)
702
- # --------------------------------------------------------------------------
703
-
704
- # 生效条件:以 root 实例化后账本固定指向 os.path.join(root, LEDGER_FILE),其 _load/_save/note/resume_point/sessions/summary 均以该路径为唯一读写对象;
705
- class SessionLedger:
706
- """会话水位账本:<root>/_sessions.json。
707
-
708
- 与 sources.Ingestor 的水位互补:Ingestor 记「某个源读到哪」,
709
- 这里记「某个会话发生过什么」——重启后按会话给出续接点。
710
- """
711
-
712
- # 生效条件:传入 root 时置 self.root=root 且 self.path=os.path.join(root, LEDGER_FILE),不做其他校验;
713
- def __init__(self, root: str):
714
- self.root = root
715
- self.path = os.path.join(root, LEDGER_FILE)
716
-
717
- # 生效条件:self.path(root/LEDGER_FILE)可读且 json 结果为 dict、且 d.get("sessions") 也是 dict 时返回该 d;self.path 不可读、json.load 抛 ValueError/OSError、或 d 非 dict / sessions 非 dict 时返回 {"schema": 1, "sessions": {}};
718
- def _load(self):
719
- if os.path.exists(self.path):
720
- try:
721
- with open(self.path, encoding="utf-8") as f:
722
- d = json.load(f)
723
- if isinstance(d, dict) and isinstance(d.get("sessions"), dict):
724
- return d
725
- except (ValueError, OSError):
726
- pass
727
- return {"schema": 1, "sessions": {}}
728
-
729
- # 生效条件:传入 d 时执行 atomic_write(self.path, json.dumps(d, ensure_ascii=False, indent=1)),自身无返回值;
730
- def _save(self, d):
731
- atomic_write(self.path, json.dumps(d, ensure_ascii=False, indent=1))
732
-
733
- # 生效条件:以 session 为键 setdefault 会话记录后写 events=int(s.get("events") or 0)+int(n)(n 默认 1,n=0 时计数不变),t 非 None 时写 last_t 且 first_t 为空时一并写入,seq 非 None 时写 last_seq,actor 为真值时写 actor,最后写 updated_at 并 _save,返回 dict(s, session=session);
734
- def note(self, session: str, *, t=None, seq=None, n: int = 1,
735
- actor: str = None) -> dict:
736
- """记一笔会话活动(事件计数 + 最后 (t, seq) 水位)。"""
737
- d = self._load()
738
- s = d["sessions"].setdefault(
739
- session, {"events": 0, "first_t": None, "last_t": None})
740
- s["events"] = int(s.get("events") or 0) + int(n)
741
- if t is not None:
742
- s["last_t"] = float(t)
743
- if s.get("first_t") is None:
744
- s["first_t"] = float(t)
745
- if seq is not None:
746
- s["last_seq"] = seq
747
- if actor:
748
- s["actor"] = actor
749
- s["updated_at"] = time.time()
750
- self._save(d)
751
- return dict(s, session=session)
752
-
753
- # 生效条件:_load()["sessions"].get(session) 为假值(键缺失或值为空 dict)时返回 {"session": session, "resume": None, "events": 0};否则返回 {"session": session, "events": s.get("events", 0), "resume": {"t": s.get("last_t"), "seq": s.get("last_seq")}, "last_t": s.get("last_t"), "actor": s.get("actor")};
754
- def resume_point(self, session: str) -> dict:
755
- """重启续接点:(t, seq) 之后的事件才是新的。"""
756
- s = self._load()["sessions"].get(session)
757
- if not s:
758
- return {"session": session, "resume": None, "events": 0}
759
- return {"session": session, "events": s.get("events", 0),
760
- "resume": {"t": s.get("last_t"), "seq": s.get("last_seq")},
761
- "last_t": s.get("last_t"), "actor": s.get("actor")}
762
-
763
- # 生效条件:无参调用时返回 dict(self._load()["sessions"]) 的浅拷贝,账本缺失/损坏时 _load 回落默认值故此处为 {};
764
- def sessions(self) -> dict:
765
- return dict(self._load()["sessions"])
766
-
767
- # 生效条件:取 d=_load()["sessions"] 后返回 {"sessions": len(d), "events": sum(int(v.get("events") or 0)), "latest": max(v.get("last_t") or 0) if d else None},d 为空时 latest 为 None;
768
- def summary(self) -> dict:
769
- d = self._load()["sessions"]
770
- return {"sessions": len(d),
771
- "events": sum(int(v.get("events") or 0) for v in d.values()),
772
- "latest": max((v.get("last_t") or 0) for v in d.values())
773
- if d else None}
774
-
775
-
776
- # 生效条件:os.path.exists(os.path.join(cg.root, "_sources.json")) 为真且 json.load 成功时返回 dict((d or {}).get("sources") or {})(d 或 sources 为假值即回落 {});该路径不可读或 json.load 抛 ValueError/OSError 时返回 {};
777
- def watermarks(cg) -> dict:
778
- """源视角水位快照(读 sources.Ingestor 的 _sources.json)。"""
779
- p = os.path.join(cg.root, "_sources.json")
780
- if not os.path.exists(p):
781
- return {}
782
- try:
783
- with open(p, encoding="utf-8") as f:
784
- d = json.load(f)
785
- except (ValueError, OSError):
786
- return {}
787
- return dict((d or {}).get("sources") or {})
788
-
789
-
790
- # --------------------------------------------------------------------------
791
- # 常驻循环
792
- # --------------------------------------------------------------------------
793
-
794
- # 生效条件:传入 cg 即构造实例并把 self.cg 指向它,name/beat_interval/heal_interval/auto_heal/scrub_interval/auto_scrub/evolve_interval/auto_evolve/tidy_interval/auto_tidy 用各默认值(DEFAULT_* 与 False/True)经 float()/bool() 落为 self 属性,ledger 为假值(默认 None)时回落 SessionLedger(cg.root),d 经 net_dir(d) 赋值,其余运行态字段初始化为 False/None/空列表/空 Event/Lock
795
- class SustainLoop:
796
- """常驻自维持循环:后台线程周期心跳 + 周期巡检 + 必要时自愈。
797
-
798
- daemon 线程,进程退出即消失;`stop()` 会清掉自己的心跳戳,
799
- 让对端立刻看到「正常下线」而不是「失联」。
800
- """
801
-
802
- # 生效条件:传入 cg 时按 name 与各 DEFAULT_* 默认值初始化——self.d=net_dir(d)(d 假值时回落 MDCG_SUSTAIN_DIR/~/ .mdcg/sustain)、self.ledger=ledger or SessionLedger(cg.root)(ledger 假值时新建),beat/heal/scrub/evolve/tidy 间隔 float() 化、auto_heal/auto_scrub/auto_evolve/auto_tidy bool() 化后存为实例属性;
803
- def __init__(self, cg, name: str = "md_cg", *,
804
- beat_interval: float = DEFAULT_BEAT_INTERVAL,
805
- heal_interval: float = DEFAULT_HEAL_INTERVAL,
806
- auto_heal: bool = True, d: str = None,
807
- ledger: SessionLedger = None,
808
- scrub_interval: float = DEFAULT_SCRUB_INTERVAL,
809
- auto_scrub: bool = False,
810
- evolve_interval: float = DEFAULT_EVOLVE_INTERVAL,
811
- auto_evolve: bool = False,
812
- tidy_interval: float = DEFAULT_TIDY_INTERVAL,
813
- auto_tidy: bool = False):
814
- self.cg = cg
815
- self.name = name
816
- self.beat_interval = float(beat_interval)
817
- self.heal_interval = float(heal_interval)
818
- self.auto_heal = bool(auto_heal)
819
- self.d = net_dir(d)
820
- self.ledger = ledger or SessionLedger(cg.root)
821
- self.scrub_interval = float(scrub_interval)
822
- self.auto_scrub = bool(auto_scrub)
823
- self.evolve_interval = float(evolve_interval)
824
- self.auto_evolve = bool(auto_evolve)
825
- self.tidy_interval = float(tidy_interval)
826
- self.auto_tidy = bool(auto_tidy)
827
- self.task_running = False
828
- self.beats = 0
829
- self.last_beat = None
830
- self.last_diagnose = None
831
- self.heals = []
832
- self.last_scrub = None
833
- self.scrubs = []
834
- self.last_evolve = None
835
- self.evolves = []
836
- self.last_tidy = None
837
- self.tidys = []
838
- # 数据健康不变量断言集结论(Pi⑤):与 tidy 同节奏,只取结论不落盘
839
- self.last_conformance = None
840
- self._started_at = None
841
- self._th = None
842
- self._stop = threading.Event()
843
- self._lock = threading.Lock()
844
-
845
- # ---- 心跳 ----
846
-
847
- # 生效条件:task_running 非 None 时先置 self.task_running=bool(task_running),再 write_stamp(self.name, self.d, task_running=self.task_running, root=self.cg.root, uptime=... if self._started_at else 0.0),beats 自增并记 last_beat=rec["ts"],返回该 rec;
848
- def beat(self, task_running: bool = None) -> dict:
849
- if task_running is not None:
850
- self.task_running = bool(task_running)
851
- rec = write_stamp(
852
- self.name, self.d, task_running=self.task_running, root=self.cg.root,
853
- uptime=round(time.time() - self._started_at, 3)
854
- if self._started_at else 0.0)
855
- self.beats += 1
856
- self.last_beat = rec["ts"]
857
- return rec
858
-
859
- # ---- 生命周期 ----
860
-
861
- # 生效条件:不适用(无必需形参与模块级常量)
862
- def start(self):
863
- if self._th is not None and self._th.is_alive():
864
- return self
865
- self._started_at = time.time()
866
- self._stop.clear()
867
- self.beat()
868
- self._th = threading.Thread(target=self._run, name="mdcg-sustain",
869
- daemon=True)
870
- self._th.start()
871
- return self
872
-
873
- # 生效条件:调用时置 _stop 事件,self._th 非 None 时 join(timeout)(默认 3.0)后置为 None,随后 clear_stamp(self.name, self.d),返回 self;
874
- def stop(self, timeout: float = 3.0):
875
- self._stop.set()
876
- if self._th is not None:
877
- self._th.join(timeout)
878
- self._th = None
879
- clear_stamp(self.name, self.d)
880
- return self
881
-
882
- # 生效条件:self._stop 未置位期间轮询,按 beat_interval/heal_interval/scrub_interval/evolve_interval/tidy_interval 到期分别执行 beat 与 _tick_heal/_tick_scrub/_tick_evolve/_tick_tidy,各 tick 抛出的异常被吞掉不中断循环,末尾以 _stop.wait(_POLL) 休眠;
883
- def _run(self):
884
- next_beat = time.time() + self.beat_interval
885
- next_heal = time.time() + self.heal_interval
886
- next_scrub = time.time() + self.scrub_interval
887
- next_evolve = time.time() + self.evolve_interval
888
- next_tidy = time.time() + self.tidy_interval
889
- while not self._stop.is_set():
890
- now = time.time()
891
- if now >= next_beat:
892
- try:
893
- self.beat()
894
- except Exception:
895
- pass # 心跳失败不中断常驻
896
- next_beat = now + self.beat_interval
897
- if now >= next_heal:
898
- try:
899
- self._tick_heal()
900
- except Exception:
901
- pass # 巡检失败不中断常驻
902
- next_heal = now + self.heal_interval
903
- if now >= next_scrub:
904
- try:
905
- self._tick_scrub()
906
- except Exception:
907
- pass # 自净失败不中断常驻
908
- next_scrub = now + self.scrub_interval
909
- if now >= next_evolve:
910
- try:
911
- self._tick_evolve()
912
- except Exception:
913
- pass # 演化巡检失败不中断常驻
914
- next_evolve = now + self.evolve_interval
915
- if now >= next_tidy:
916
- try:
917
- self._tick_tidy()
918
- except Exception:
919
- pass # 整理巡检失败不中断常驻
920
- next_tidy = now + self.tidy_interval
921
- self._stop.wait(_POLL)
922
-
923
- # 生效条件:以 apply=self.auto_tidy 调 writelimit.tidy_contextual(self.cg, actor="sustain_tidy"),把 t/scanned/groups/members/applied_count/auto_tidy 记入 self.last_tidy 与 tidys(仅保留最近 20 条),随后调 _tick_conformance();auto_tidy=False(默认)时只盘点不落盘;
924
- def _tick_tidy(self):
925
- """整理巡检(contextual 流水治理·读侧):同构组聚合 + 成员降权。
926
-
927
- 确定性动作、永不删节点;`auto_tidy=False`(默认)只盘点不落盘。
928
- 治理对象:单日批次流水(「批次247收官记忆」×163 那类同模板写入)
929
- —— 写入侧限流(writelimit.check)拦增量,本巡检收敛存量。
930
- """
931
- from . import writelimit
932
- r = writelimit.tidy_contextual(self.cg, apply=self.auto_tidy,
933
- actor="sustain_tidy")
934
- rec = {"t": r["t"], "scanned": r["scanned"], "groups": r["groups"],
935
- "members": r["members"],
936
- "applied_count": r.get("applied_count", 0),
937
- "auto_tidy": self.auto_tidy}
938
- self.last_tidy = rec
939
- with self._lock:
940
- self.tidys.append(rec)
941
- self.tidys = self.tidys[-20:]
942
- self._tick_conformance()
943
-
944
- # 生效条件:把 conformance.report_summary(self.cg) 的结论写入 self.last_conformance;该调用抛异常时写入 {"ok": False, "verdict": "BLINDSPOT", "error": "<类型名>: <消息>"};
945
- def _tick_conformance(self):
946
- """数据健康不变量断言集(Pi⑤)——与 tidy 同节奏,**只取结论**。
947
-
948
- 复用 tidy 的 6h 节奏而不新开周期:两者都是「存量数据体检」,且都是
949
- 只读巡检。纪律:本断言集**只告警不改数据**,故 `auto_*` 在此无意义;
950
- `check_paths=False` 跳过 1.1 万次 stat,`_audit.jsonl` 只读尾窗。
951
- """
952
- from . import conformance
953
- try:
954
- self.last_conformance = conformance.report_summary(self.cg)
955
- except Exception as e: # noqa: BLE001
956
- self.last_conformance = {"ok": False, "verdict": "BLINDSPOT",
957
- "error": f"{type(e).__name__}: {e}"}
958
-
959
- # 生效条件:恒以 evolution_candidates(self.cg) 只读盘点并记入 last_evolve 与 evolves(保留最近 20 条);仅当 self.auto_evolve 为真且 ev["importance_drift"]["n"] 为真时才额外执行 weights.recalc(self.cg, apply=True, actor="sustain_evolve"),其异常写入 rec["applied"];
960
- def _tick_evolve(self):
961
- """演化巡检(G7):盘点固化/重要性候选 —— 让「有能力」变成「有驱动」。
962
-
963
- 恒只读盘点并记账;`auto_evolve=True` 时才额外落盘**确定性**动作
964
- (仅重要性重算,可 rollback)。固化依赖 LLM,巡检只报候补量、交人工。
965
- """
966
- ev = evolution_candidates(self.cg)
967
- rec = {"t": ev["t"], "candidates": ev["candidates"],
968
- "ccg_backlog": ev["ccg_backlog"]["n"],
969
- "importance_drift": ev["importance_drift"]["n"],
970
- "auto_evolve": self.auto_evolve, "applied": []}
971
- if self.auto_evolve and ev["importance_drift"]["n"]:
972
- from . import weights
973
- try:
974
- r = weights.recalc(self.cg, apply=True, actor="sustain_evolve")
975
- rec["applied"].append({"fix": "importance",
976
- "written": r["written"],
977
- "batch": r["batch"]})
978
- except Exception as e: # 演化失败不能拖垮常驻
979
- rec["applied"].append({"fix": "importance",
980
- "error": f"{type(e).__name__}: {e}"})
981
- self.last_evolve = rec
982
- with self._lock:
983
- self.evolves.append(rec)
984
- self.evolves = self.evolves[-20:]
985
-
986
- # 生效条件:以 dry_run=not self.auto_scrub 调 scrub.sweep(self.cg),把 t/ok/n_issues/n_high_medium/applied/dry_run 记入 last_scrub 与 scrubs(保留最近 20 条);auto_scrub=False(默认)时 dry_run=True 只读巡检;
987
- def _tick_scrub(self):
988
- """记忆自净:抽查 → 联想 → 去污染 → 校准偏差。
989
-
990
- `auto_scrub=False`(默认)时只做只读巡检并记账,不动任何节点;
991
- 开启后才执行去污染(仍只做可逆动作、永不删节点)。
992
- """
993
- from . import scrub
994
- rep = scrub.sweep(self.cg, dry_run=not self.auto_scrub)
995
- rec = {"t": rep["t"], "ok": rep["ok"],
996
- "n_issues": rep["audit"]["n_issues"],
997
- "n_high_medium": rep["n_high_medium"],
998
- "applied": rep["decontaminate"]["applied"],
999
- "dry_run": rep["dry_run"]}
1000
- self.last_scrub = rec
1001
- with self._lock:
1002
- self.scrubs.append(rec)
1003
- self.scrubs = self.scrubs[-20:]
1004
-
1005
- # 生效条件:先 diagnose(self.cg, name=self.name) 并将 ok 与 issues code 记入 last_diagnose;仅当 self.auto_heal 为真且 rep["ok"] 为假时才调 heal(self.cg, name=self.name),其 actions 非空时把各 action 的 code 记入 heals(保留最近 20 条);
1006
- def _tick_heal(self):
1007
- rep = diagnose(self.cg, name=self.name)
1008
- self.last_diagnose = {"t": time.time(), "ok": rep["ok"],
1009
- "issues": [i["code"] for i in rep["issues"]]}
1010
- if not (self.auto_heal and not rep["ok"]):
1011
- return
1012
- res = heal(self.cg, name=self.name)
1013
- if res["actions"]:
1014
- with self._lock:
1015
- self.heals.append({"t": res["t"],
1016
- "actions": [a["code"] for a in res["actions"]]})
1017
- self.heals = self.heals[-20:]
1018
-
1019
- # ---- 状态 ----
1020
-
1021
- # 生效条件:以 read_stamp(self.name, self.d) 判定 state(有戳走 judge(age, interval=self.beat_interval, task_running=...),无戳为 "stopped"),返回含 name/running/pid/uptime(无 _started_at 时为 0.0)/beats/last_beat/各 interval 与 auto_* 开关/heals[-5:]/evolves[-5:]/tidys[-5:]/peers(self.d)/ledger.summary() 的 dict;
1022
- def status(self) -> dict:
1023
- st = read_stamp(self.name, self.d)
1024
- state = (judge(st["age"], interval=self.beat_interval,
1025
- task_running=bool(st.get("task_running")))
1026
- if st else "stopped")
1027
- return {"name": self.name, "running": bool(self._th
1028
- and self._th.is_alive()),
1029
- "pid": os.getpid(),
1030
- "uptime": round(time.time() - self._started_at, 3)
1031
- if self._started_at else 0.0,
1032
- "beats": self.beats, "last_beat": self.last_beat,
1033
- "beat_interval": self.beat_interval,
1034
- "heal_interval": self.heal_interval,
1035
- "auto_heal": self.auto_heal, "state": state,
1036
- "task_running": self.task_running,
1037
- "last_diagnose": self.last_diagnose,
1038
- "heals": self.heals[-5:],
1039
- "scrub_interval": self.scrub_interval,
1040
- "auto_scrub": self.auto_scrub,
1041
- "last_scrub": self.last_scrub,
1042
- "evolve_interval": self.evolve_interval,
1043
- "auto_evolve": self.auto_evolve,
1044
- "last_evolve": self.last_evolve,
1045
- "evolves": self.evolves[-5:],
1046
- "last_tidy": self.last_tidy,
1047
- "tidys": self.tidys[-5:],
1048
- "last_conformance": self.last_conformance,
1049
- "peers": peers(self.d),
1050
- "sessions": self.ledger.summary()}
1051
-
1052
-
1053
- _LOOPS = {}
1054
- _LOOPS_LOCK = threading.Lock()
1055
-
1056
-
1057
- # 生效条件:cg 必填,以 (os.path.abspath(cg.root), name) 为键在模块级 _LOOPS 中复用已有实例或新建 SustainLoop 并返回该实例。
1058
- def ensure_loop(cg, name: str = "md_cg", **kw) -> SustainLoop:
1059
- """按 (root, name) 复用同一个常驻循环(避免重复线程 / 重复戳)。"""
1060
- key = (os.path.abspath(cg.root), name)
1061
- with _LOOPS_LOCK:
1062
- lp = _LOOPS.get(key)
1063
- if lp is None:
1064
- lp = SustainLoop(cg, name=name, **kw)
1065
- _LOOPS[key] = lp
1066
- return lp
1067
-
1068
-
1069
- # 生效条件:cg 必填,返回 _LOOPS[(os.path.abspath(cg.root), name)],该键未登记时返回 None。
1070
- def get_loop(cg, name: str = "md_cg"):
1071
- return _LOOPS.get((os.path.abspath(cg.root), name))
1072
-
1073
-
1074
- # 生效条件:模块级 _LOOPS 非空时清空该表并对每个循环调用 stop()(异常被吞),无返回值。
1075
- def stop_all():
1076
- """测试 / 进程退出用:停掉本进程创建的所有常驻循环。"""
1077
- with _LOOPS_LOCK:
1078
- loops = list(_LOOPS.values())
1079
- _LOOPS.clear()
1080
- for lp in loops:
1081
- try:
1082
- lp.stop()
1083
- except Exception:
1084
- pass
1085
-
1086
-
1087
- # 生效条件:以 read_stamp(name) 有戳时返回 heartbeat={'age': round(st["age"],1), 'state': judge(...), 'pid', 'task_running'}、无戳时 {'state':'absent'},loop 取 get_loop(cg, name) 有值时给 running/beats/state、无值时 {'running': False},并附带 SessionLedger(cg.root).summary()、scrub_summary(cg)、evolve_summary(cg)、provenance_summary(cg);
1088
- def summary(cg, name: str = "md_cg") -> dict:
1089
- """并入 health_os 的轻量摘要(只读戳与计数,不做巡检)。"""
1090
- st = read_stamp(name)
1091
- lp = get_loop(cg, name)
1092
- return {
1093
- "heartbeat": ({"age": round(st["age"], 1),
1094
- "state": judge(st["age"],
1095
- task_running=bool(st.get("task_running"))),
1096
- "pid": st.get("pid"),
1097
- "task_running": bool(st.get("task_running"))}
1098
- if st else {"state": "absent"}),
1099
- "loop": ({"running": bool(lp._th and lp._th.is_alive()),
1100
- "beats": lp.beats, "state": lp.status()["state"]}
1101
- if lp else {"running": False}),
1102
- "sessions": SessionLedger(cg.root).summary(),
1103
- "scrub": scrub_summary(cg),
1104
- "evolve": evolve_summary(cg),
1105
- "provenance": provenance_summary(cg),
1106
- }
1107
-
1108
-
1109
- # 生效条件:cg 必填,可导入 provenance 且其 summary(cg) 调用成功时返回该摘要,异常时返回 {}。
1110
- def provenance_summary(cg) -> dict:
1111
- """派生溯源摘要(G8,只读;失败不抛,避免拖垮 health_os)。"""
1112
- try:
1113
- from . import provenance as _pv
1114
- return _pv.summary(cg)
1115
- except Exception: # noqa: BLE001
1116
- return {}
1117
-
1118
-
1119
- # 生效条件:cg 必填,evolution_candidates(cg, top=3) 成功时返回 candidates/ccg_backlog/importance_drift 计数摘要,异常时返回 {}。
1120
- def evolve_summary(cg) -> dict:
1121
- """演化巡检摘要(只读;失败不抛,避免拖垮 health_os)。"""
1122
- try:
1123
- ev = evolution_candidates(cg, top=3)
1124
- return {"candidates": ev["candidates"],
1125
- "ccg_backlog": ev["ccg_backlog"]["n"],
1126
- "importance_drift": ev["importance_drift"]["n"],
1127
- "proxy": True}
1128
- except Exception: # noqa: BLE001
1129
- return {}
1130
-
1131
-
1132
- # 生效条件:cg 必填,可导入 scrub 且其 summary(cg) 调用成功时返回该摘要,异常时返回 {}。
1133
- def scrub_summary(cg) -> dict:
1134
- """记忆自净摘要(惰性导入,避免模块加载顺序耦合)。"""
1135
- try:
1136
- from . import scrub
1137
- return scrub.summary(cg)
1138
- except Exception: # noqa: BLE001
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · 持续性自维持(记忆 OS #4):常驻 / 心跳 / 自愈 / 会话续接
3
+
4
+ 路线图「常驻服务:会话 / 心跳 / 自愈」的落地。回答三个问题:
5
+
6
+ ① 别人怎么知道我还活着?—— **心跳戳**
7
+ `<net_dir>/heartbeat.<name>.stamp`(原子写,含 ts / pid / uptime / task_running)。
8
+ 分级判定对齐 Mutual Sustain Loop v1.1:正常 / 警告 / 失联,阈值按心跳间隔的
9
+ 2.5× / 3.5×;**任务执行中阈值放宽**(working factor),避免长任务被误判死亡。
10
+ 戳写在仓库外(默认 ~/.mdcg/sustain),不污染知识库,也不随仓库泄露。
11
+
12
+ ② 进程被杀会不会留下坏状态?—— **自愈(diagnose → heal)**
13
+ 诊断只读、不改动;修复动作幂等且逐条留审计:
14
+ · 索引漂移 / 孤儿索引 → 重建索引(索引是派生物,可安全重建)
15
+ · 索引增量分片积压 → 合并进快照
16
+ · 陈旧临时文件 → 清理(被杀死的写者留下的唯一命名 tmp)
17
+ · 日志半截行 → 补换行(否则下一条记录会粘在断行上)
18
+ · 私有节点不可解 → **只报告不修**(缺密钥是权限事实,不是故障)
19
+ 原则:修复只碰派生物(索引 / 临时文件 / 日志边界),**永不删节点**。
20
+
21
+ ③ 重启后从哪继续?—— **会话水位(SessionLedger)**
22
+ `<root>/_sessions.json` 记录每个会话的 (last_t, last_seq, events),
23
+ 与 `sources.Ingestor` 的 `_sources.json`(源视角水位)互补;重启后
24
+ `resume_point(session)` 直接给出续接点,不重复摄取、不丢事件。
25
+
26
+ ④ 能力不会被饿死?—— **演化巡检(evolve)**
27
+ 自我演化的三类动作(固化 `consolidate` / 重算重要性 `weights` / 去污染 `scrub`)
28
+ 早已具备,缺的是**驱动源**:没人周期性问「现在有多少该固化 / 该重算的候选」。
29
+ `evolution_candidates()` 以**索引快照**为口径做只读盘点(零读节点文件、零写盘、
30
+ 确定性),并挂到常驻循环的 `_tick_evolve()` 上。纪律与 self-heal 一致且更严:
31
+ · 巡检恒只读,`auto_evolve=False`(默认)时**只记账不动库**;
32
+ · 自愈只放行**确定性且可回滚**的动作(重要性重算,有 rollback);
33
+ · 依赖 LLM 的固化**永不自动跑**——巡检报出候选数,交人工另批。
34
+
35
+ ⑤ 演进血缘有没有断?—— **派生溯源巡检(G8)**
36
+ 新增节点在建链时把 `derived_from` 写进 frontmatter 并追加到 `<root>/_link.jsonl`。
37
+ 可台账会丢、节点会被删,于是血缘会出现**悬空边**(子/父节点已不在库里)。
38
+ `diagnose()` 每次都做只读盘点(零读节点文件:索引已带 `derived_from`),把悬空边
39
+ 报为 `provenance_dangling`(severity=info、**无自动修复**)——关系事实的去留由人
40
+ 处置,不给「自动删边」这种会篡改历史的动作。
41
+
42
+ ⑥ 对端挂了谁来救?—— **互维闭环(P-T-110 最小投影 · #30)**
43
+ 两个灵枢互为维生系统:`mutual_watch` 读对端心跳 → 失联则**幂等拉起**
44
+ (pid 探活防误判 + 冷却防风暴)→ **验戳新鲜闭合**:拉起后必须轮询到
45
+ 对端戳变新才算救活,否则如实报 `mutual_peer_unresponsive`——这正是
46
+ mutual-sustain-loop v1.1 §7 的 W4 部署教训(「拉起后应验证对端戳新鲜度,
47
+ 当时缺该校验」)的机制化:不假装成功。`mutual_status` 做双亡检测:
48
+ 自己也失联时互维本身不可信 → 显式上报外部告警语义,绝不静默。
49
+
50
+ 零第三方依赖。
51
+ """
52
+ from __future__ import annotations
53
+
54
+ import json
55
+ import os
56
+ import threading
57
+ import time
58
+
59
+ from . import crypto
60
+ from .fsutil import append_jsonl, atomic_write, ends_mid_line
61
+ from .mdcg import LAYERS
62
+
63
+ STAMP_VERSION = 1
64
+ SUSTAIN_LOG = "_sustain.jsonl"
65
+ LEDGER_FILE = "_sessions.json"
66
+
67
+ DEFAULT_BEAT_INTERVAL = 600.0 # 心跳间隔 10min(对齐 mutual-sustain-loop)
68
+ DEFAULT_HEAL_INTERVAL = 300.0 # 自愈巡检 5min
69
+ DEFAULT_SCRUB_INTERVAL = 3600.0 # 记忆自净(抽查/去污染/校准)1h
70
+ DEFAULT_EVOLVE_INTERVAL = 7200.0 # 演化巡检(固化/重要性候选盘点)2h;只读
71
+ DEFAULT_TIDY_INTERVAL = 21600.0 # 整理巡检(contextual 同构组聚合)6h
72
+ DEFAULT_WARN_FACTOR = 2.5 # 2.5× 心跳间隔 → 警告
73
+ DEFAULT_DEAD_FACTOR = 3.5 # 3.5× → 失联
74
+ DEFAULT_WORKING_FACTOR = 2.0 # 任务执行中阈值 ×2
75
+ STALE_TEMP_AGE = 3600.0 # 临时文件超过 1h 视为陈旧
76
+ _POLL = 0.2 # 循环轮询步长(常驻进程 CPU 可忽略)
77
+
78
+
79
+ # --------------------------------------------------------------------------
80
+ # 心跳
81
+ # --------------------------------------------------------------------------
82
+
83
+ # 生效条件:d 为真值时返回 d,d 为 None/空串时回落 MDCG_SUSTAIN_DIR,该环境变量也未设或为空串时返回 os.path.join(os.path.expanduser("~"), ".mdcg", "sustain");
84
+ def net_dir(d: str = None) -> str:
85
+ """心跳戳目录:显式 → MDCG_SUSTAIN_DIR → ~/.mdcg/sustain(仓库外)。"""
86
+ return (d or os.environ.get("MDCG_SUSTAIN_DIR")
87
+ or os.path.join(os.path.expanduser("~"), ".mdcg", "sustain"))
88
+
89
+
90
+ # 生效条件:给定必填 name,返回 net_dir(d) 下 heartbeat.<name>.stamp 的拼接路径。
91
+ def stamp_path(name: str, d: str = None) -> str:
92
+ return os.path.join(net_dir(d), f"heartbeat.{name}.stamp")
93
+
94
+
95
+ # 生效条件:name 必填,组装含模块常量 STAMP_VERSION 与 task_running 的戳记录、原子写入 stamp_path(name,d) 后返回该 rec。
96
+ def write_stamp(name: str, d: str = None, **extra) -> dict:
97
+ """写一次心跳(原子替换)。extra 会并入戳内容(None 值丢弃)。"""
98
+ rec = {"v": STAMP_VERSION, "name": name, "ts": time.time(),
99
+ "pid": os.getpid(),
100
+ "iso": time.strftime("%Y-%m-%dT%H:%M:%S"),
101
+ "task_running": bool(extra.pop("task_running", False))}
102
+ for k, v in extra.items():
103
+ if v is not None:
104
+ rec[k] = v
105
+ atomic_write(stamp_path(name, d),
106
+ json.dumps(rec, ensure_ascii=False, indent=1))
107
+ return rec
108
+
109
+
110
+ # 生效条件:stamp_path(name, d) 所得路径上 os.path.exists 为真且 json.load 结果为 dict 时,返回该 rec 并附加 age=max(0.0, time.time() - float(rec.get("ts") or 0));该路径不可读、json.load 抛 ValueError/OSError 或 rec 非 dict 时返回 None;
111
+ def read_stamp(name: str, d: str = None):
112
+ """读心跳戳并附 age(秒);不存在 / 损坏 → None。"""
113
+ p = stamp_path(name, d)
114
+ if not os.path.exists(p):
115
+ return None
116
+ try:
117
+ with open(p, encoding="utf-8") as f:
118
+ rec = json.load(f)
119
+ except (ValueError, OSError):
120
+ return None
121
+ if not isinstance(rec, dict):
122
+ return None
123
+ rec["age"] = max(0.0, time.time() - float(rec.get("ts") or 0))
124
+ return rec
125
+
126
+
127
+ # 生效条件:对 stamp_path(name,d) 执行 os.remove,OSError 被静默忽略,无返回值。
128
+ def clear_stamp(name: str, d: str = None):
129
+ try:
130
+ os.remove(stamp_path(name, d))
131
+ except OSError:
132
+ pass
133
+
134
+
135
+ # 生效条件:age 为 None 返回 'absent';否则以 factor=working if task_running else 1.0 比较 age 与 interval*warn*factor、interval*dead*factor,分别返回 'ok'/'warning'/'dead'。
136
+ def judge(age, *, interval: float = DEFAULT_BEAT_INTERVAL,
137
+ task_running: bool = False, warn: float = DEFAULT_WARN_FACTOR,
138
+ dead: float = DEFAULT_DEAD_FACTOR,
139
+ working: float = DEFAULT_WORKING_FACTOR) -> str:
140
+ """按心跳年龄分级:ok / warning / dead / absent。
141
+
142
+ task_running=True 时阈值整体放宽 working 倍——长任务期间不写心跳是正常的,
143
+ 若不放宽会把「正在干活」误判成「已死」。
144
+ """
145
+ if age is None:
146
+ return "absent"
147
+ factor = working if task_running else 1.0
148
+ if age <= interval * warn * factor:
149
+ return "ok"
150
+ if age <= interval * dead * factor:
151
+ return "warning"
152
+ return "dead"
153
+
154
+
155
+ # 生效条件:net_dir(d) 可列为目录时遍历 heartbeat.*.stamp 并返回含 age 与 state 的记录列表,否则返回 []。
156
+ def peers(d: str = None):
157
+ """列出本机所有心跳戳(含状态)。"""
158
+ base = net_dir(d)
159
+ if not os.path.isdir(base):
160
+ return []
161
+ out = []
162
+ for fn in sorted(os.listdir(base)):
163
+ if not (fn.startswith("heartbeat.") and fn.endswith(".stamp")):
164
+ continue
165
+ rec = read_stamp(fn[len("heartbeat."):-len(".stamp")], base)
166
+ if rec:
167
+ rec["state"] = judge(rec["age"],
168
+ task_running=bool(rec.get("task_running")))
169
+ out.append(rec)
170
+ return out
171
+
172
+
173
+ # --------------------------------------------------------------------------
174
+ # 互维闭环(P-T-110 最小投影 · #30)
175
+ # --------------------------------------------------------------------------
176
+
177
+ DEFAULT_RESTART_COOLDOWN = 300.0 # 同一对端两次拉起的最小间隔(防风暴)
178
+ DEFAULT_FRESH_TIMEOUT = 60.0 # 拉起后等待对端戳变新鲜的窗口
179
+
180
+
181
+ # 生效条件:pid 可转 int 且 >0 时按探活(os.name=='nt' 用 OpenProcess+WaitForSingleObject,否则 os.kill(pid,0))返回 True,转 int 失败或 pid<=0 或探活失败返回 False。
182
+ def pid_alive(pid) -> bool:
183
+ """进程探活(零第三方依赖):Windows=OpenProcess+WaitForSingleObject;
184
+ posix=os.kill(pid,0)。pid 无效 / 已退出 / 权限外 → False(诚实)。"""
185
+ try:
186
+ pid = int(pid)
187
+ except (TypeError, ValueError):
188
+ return False
189
+ if pid <= 0:
190
+ return False
191
+ if os.name == "nt":
192
+ import ctypes
193
+ SYNCHRONIZE = 0x00100000
194
+ WAIT_TIMEOUT = 0x00000102
195
+ k32 = ctypes.windll.kernel32
196
+ h = k32.OpenProcess(SYNCHRONIZE, False, pid)
197
+ if not h:
198
+ return False
199
+ try:
200
+ return k32.WaitForSingleObject(h, 0) == WAIT_TIMEOUT
201
+ finally:
202
+ k32.CloseHandle(h)
203
+ try:
204
+ os.kill(pid, 0)
205
+ return True
206
+ except OSError:
207
+ return False
208
+
209
+
210
+ # 生效条件:cmd 为 argv 列表时以 stdout/stderr=DEVNULL、不经 shell 的 subprocess.Popen 拉起(nt 附加 DETACHED|NEW_GROUP creationflags),返回该 Popen 对象。
211
+ def _default_spawner(cmd):
212
+ """分离式拉起:argv 列表、不经 shell、输出弃置(Windows 完全脱离父控制台)。"""
213
+ import subprocess
214
+ kw = {}
215
+ if os.name == "nt":
216
+ kw["creationflags"] = (0x00000008 | 0x00000200) # DETACHED|NEW_GROUP
217
+ return subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
218
+ stderr=subprocess.DEVNULL, **kw)
219
+
220
+
221
+ # 生效条件:root 与 peer 必填,扫描 root/SUSTAIN_LOG(模块常量)返回 op='mutual'、action='restart' 且 detail 以 peer 开头的记录中最大 t,无匹配返回 0.0。
222
+ def _last_mutual_restart(root: str, peer: str) -> float:
223
+ """_sustain.jsonl 里该对端最近一次互维拉起时间(无 → 0.0,审计即状态)。"""
224
+ last = 0.0
225
+ try:
226
+ with open(os.path.join(root, SUSTAIN_LOG), encoding="utf-8") as f:
227
+ for line in f:
228
+ try:
229
+ r = json.loads(line)
230
+ except ValueError:
231
+ continue
232
+ if (r.get("op") == "mutual" and r.get("action") == "restart"
233
+ and str(r.get("detail") or "").startswith(str(peer))):
234
+ last = max(last, float(r.get("t") or 0.0))
235
+ except OSError:
236
+ pass
237
+ return last
238
+
239
+
240
+ # 生效条件:cg 与 peer 必填,读对端戳判状态:ok/warning 直接返回 action='none';dead/absent 时依次按 pid_alive、restart_cooldown、restart_cmd 有无走 alive_but_stale/restart_cooldown/dead_unhandled/拉起并验戳,返回 out。
241
+ def mutual_watch(cg, peer: str, *, restart_cmd=None, d: str = None,
242
+ interval: float = DEFAULT_BEAT_INTERVAL,
243
+ restart_cooldown: float = DEFAULT_RESTART_COOLDOWN,
244
+ fresh_timeout: float = DEFAULT_FRESH_TIMEOUT, poll: float = 0.5,
245
+ spawner=None) -> dict:
246
+ """互维守护(单侧一次检查):读对端心跳 → 失联则幂等拉起 → 验戳闭合。
247
+
248
+ 判定序(P-T-110 语义,全链路审计进 _sustain.jsonl):
249
+ ok/warning → 不动作;
250
+ dead/absent:
251
+ ① 误判防护:戳里 pid 仍存活 → `alive_but_stale`(长任务未标
252
+ task_running?)——**不拉起**,不杀活进程;
253
+ ② 冷却:距上次拉起 < restart_cooldown → `restart_cooldown`;
254
+ ③ 无 restart_cmd → `dead_unhandled`(ok=False,诚实上报没手段,
255
+ 不假装能救);
256
+ ④ 拉起(spawner 或分离式 Popen)→ **验戳新鲜闭合**(W4 补丁):
257
+ 轮询对端戳 ts 超过拉起前值才算 `recovered=True`;超时 →
258
+ `recovered=False` + `mutual_peer_unresponsive`——对端拉起后
259
+ 互维未激活(v1.1 §7 的 W4 场景)会被显式暴露,不假装成功。
260
+ """
261
+ rec = read_stamp(peer, d)
262
+ state = (judge(rec["age"], interval=interval,
263
+ task_running=bool(rec.get("task_running")))
264
+ if rec else "absent")
265
+ out = {"ok": True, "peer": peer, "state": state, "t": time.time(),
266
+ "action": "none", "recovered": None}
267
+ if state in ("ok", "warning"):
268
+ return out
269
+ if rec and pid_alive(rec.get("pid")):
270
+ out.update(action="alive_but_stale",
271
+ note="对端进程存活但戳陈旧(长任务未标 task_running?)——不拉起")
272
+ _audit(cg.root, "mutual", "alive_but_stale", peer)
273
+ return out
274
+ last = _last_mutual_restart(cg.root, peer)
275
+ if last and (time.time() - last) < restart_cooldown:
276
+ out.update(action="restart_cooldown",
277
+ note="距上次拉起 %.1fs,冷却中" % (time.time() - last))
278
+ return out
279
+ if not restart_cmd:
280
+ out.update(ok=False, action="dead_unhandled",
281
+ note="对端失联且未提供 restart_cmd——诚实上报,不假装能救")
282
+ _audit(cg.root, "mutual", "dead_unhandled", peer)
283
+ return out
284
+ before_ts = float((rec or {}).get("ts") or 0.0)
285
+ spawn = spawner or _default_spawner
286
+ try:
287
+ spawn(restart_cmd)
288
+ except Exception as e: # noqa: BLE001
289
+ out.update(ok=False, action="restart_error",
290
+ error="%s: %s" % (type(e).__name__, e))
291
+ _audit(cg.root, "mutual", "restart_error", peer)
292
+ return out
293
+ deadline = time.time() + fresh_timeout
294
+ while time.time() < deadline:
295
+ r2 = read_stamp(peer, d)
296
+ if r2 and float(r2.get("ts") or 0.0) > before_ts:
297
+ out.update(action="restarted", recovered=True, fresh_ts=r2.get("ts"))
298
+ _audit(cg.root, "mutual", "restart", peer)
299
+ return out
300
+ time.sleep(poll)
301
+ out.update(ok=False, action="restarted", recovered=False,
302
+ note=("拉起命令已执行但对端戳未更新(互维未激活?)——"
303
+ "mutual_peer_unresponsive,不假装成功"))
304
+ _audit(cg.root, "mutual", "restart_unverified", peer)
305
+ return out
306
+
307
+
308
+ # 生效条件:cg 与 peer 必填,分别按 name 与 peer 读戳判状态,仅当自身与对端状态均属 ('dead','absent') 时返回 ok=False、both_dead=True。
309
+ def mutual_status(cg, peer: str, *, d: str = None, name: str = "md_cg",
310
+ interval: float = DEFAULT_BEAT_INTERVAL) -> dict:
311
+ """互维状态 + 双亡检测:自己也失联时互维不可信 → 显式外部告警语义。"""
312
+ mine, theirs = read_stamp(name, d), read_stamp(peer, d)
313
+ my_state = (judge(mine["age"], interval=interval,
314
+ task_running=bool(mine.get("task_running")))
315
+ if mine else "absent")
316
+ peer_state = (judge(theirs["age"], interval=interval,
317
+ task_running=bool(theirs.get("task_running")))
318
+ if theirs else "absent")
319
+ dead = ("dead", "absent")
320
+ both_dead = my_state in dead and peer_state in dead
321
+ return {"ok": not both_dead, "self": my_state, "peer": peer_state,
322
+ "both_dead": both_dead,
323
+ "note": ("双向同时失联:互维本身已不可信,须外部告警(P-T-110 风险表)"
324
+ if both_dead else "互维链路可用")}
325
+
326
+
327
+ # --------------------------------------------------------------------------
328
+ # 诊断(只读)
329
+ # --------------------------------------------------------------------------
330
+
331
+ # 生效条件:root 必填,对模块常量 LAYERS 各层 os.walk 统计以 .md 结尾的文件名个数并返回 n(不解析正文)。
332
+ def _count_node_files(root: str) -> int:
333
+ """轻量统计磁盘节点数(只数文件名,不解析正文)。"""
334
+ n = 0
335
+ for layer in LAYERS:
336
+ for _dp, _dirs, files in os.walk(os.path.join(root, layer)):
337
+ n += sum(1 for f in files if f.endswith(".md"))
338
+ return n
339
+
340
+
341
+ # 生效条件:root 与 older_than 必填,返回 root 及其 LAYERS 各层下以 '.' 开头、名含 '.tmp-' 且 mtime 早于 now-older_than 的路径列表。
342
+ def _list_stale_temps(root: str, older_than: float):
343
+ now, out = time.time(), []
344
+ bases = [root] + [os.path.join(root, l) for l in LAYERS]
345
+ for base in bases:
346
+ if not os.path.isdir(base):
347
+ continue
348
+ for name in os.listdir(base):
349
+ if ".tmp-" not in name or not name.startswith("."):
350
+ continue
351
+ p = os.path.join(base, name)
352
+ try:
353
+ if now - os.path.getmtime(p) > older_than:
354
+ out.append(p)
355
+ except OSError:
356
+ pass
357
+ return out
358
+
359
+
360
+ # 生效条件:root 必填,返回 root 顶层以 .jsonl/.log 结尾且 ends_mid_line 为真的路径列表;root 非目录返回 []。
361
+ def _half_line_logs(root: str):
362
+ """顶层行式日志里末尾不是换行的(写者被杀留下的半截记录)。"""
363
+ out = []
364
+ if not os.path.isdir(root):
365
+ return out
366
+ for name in sorted(os.listdir(root)):
367
+ if not (name.endswith(".jsonl") or name.endswith(".log")):
368
+ continue
369
+ p = os.path.join(root, name)
370
+ if os.path.isfile(p) and ends_mid_line(p):
371
+ out.append(p)
372
+ return out
373
+
374
+
375
+ # 生效条件:root/_index_log 可列为目录时返回其中 .log 结尾的文件名列表,否则返回 []。
376
+ def _index_log_shards(root: str):
377
+ d = os.path.join(root, "_index_log")
378
+ if not os.path.isdir(d):
379
+ return []
380
+ return [f for f in os.listdir(d) if f.endswith(".log")]
381
+
382
+
383
+ # 生效条件:cg 具备 crypto_status 且其返回 unlocked 为假时,返回 cg.index['nodes'] 中 sensitivity 属 crypto.ENCRYPTED_LEVELS 的条目数;属性缺失、调用异常或已解锁时返回 0。
384
+ def _locked_nodes(cg) -> int:
385
+ """加密引擎在「未解锁」状态下被隔离的私有节点数(缺密钥 = 权限事实)。"""
386
+ if not hasattr(cg, "crypto_status"):
387
+ return 0
388
+ try:
389
+ st = cg.crypto_status() or {}
390
+ except Exception:
391
+ return 0
392
+ if st.get("unlocked"):
393
+ return 0
394
+ nodes = (getattr(cg, "index", {}) or {}).get("nodes") or {}
395
+ return sum(1 for e in nodes.values()
396
+ if (e.get("sensitivity") or "") in crypto.ENCRYPTED_LEVELS)
397
+
398
+
399
+ # --------------------------------------------------------------------------
400
+ # 演化巡检(G7:给「固化 / 重要性」能力装上驱动源)
401
+ # --------------------------------------------------------------------------
402
+
403
+ #: 演化巡检项 → 对应的写入动作(`scrub` 已有独立 tick,不并入此项)
404
+ EVOLVE_FIXES = {"ccg_backlog": "consolidate_run",
405
+ "importance_drift": "importance"}
406
+
407
+
408
+ # 生效条件:nodes 与 top 必填,遍历索引条目统计缺 verification_basis 或 has_neg_conditions 的候补数 n、no_basis、no_neg 并取最多 top 个 sample,返回含 proxy=True 与 EVOLVE_FIXES['ccg_backlog'] 的字典。
409
+ def _ccg_backlog(nodes: dict, top: int) -> dict:
410
+ """固化候补量(**索引代理指标**:零读节点文件、确定性、O(N))。
411
+
412
+ 真正的固化闸门在 `consolidate`(四要素 + 验证基底 + 白箱 replay,需读正文与 LLM)。
413
+ 巡检只要**驱动信号**:索引里的 `verification_basis` / `has_neg_conditions` 已能
414
+ 区分「有/无验证基底」「有/无负条件」,足够回答「有没有货等着固化」。
415
+
416
+ 代理指标 ≠ 判定结论:报出的是**候补量**,不是「这些节点确实该固化」。
417
+ """
418
+ n = no_basis = no_neg = 0
419
+ sample = []
420
+ for nid, e in nodes.items():
421
+ mb = not e.get("verification_basis")
422
+ mn = not e.get("has_neg_conditions")
423
+ no_basis += 1 if mb else 0
424
+ no_neg += 1 if mn else 0
425
+ if mb or mn:
426
+ n += 1
427
+ if len(sample) < top:
428
+ sample.append(nid)
429
+ return {"n": n, "no_basis": no_basis, "no_neg": no_neg, "sample": sample,
430
+ "proxy": True, "fix": EVOLVE_FIXES["ccg_backlog"]}
431
+
432
+
433
+ # 生效条件:cg 必填,取 cg.index['nodes'](layer 非空时按 layer 过滤),经 weights.recalc(apply=False) 与 _ccg_backlog 汇总,返回 ok=True、readonly=True、dry_run=True 的候选盘点字典。
434
+ def evolution_candidates(cg, *, layer: str = None, top: int = 8,
435
+ min_delta: float = None) -> dict:
436
+ """演化候选盘点(G7):**只读、零读节点文件、确定性、不写盘**。
437
+
438
+ 回答「现在有多少该固化 / 该重算重要性的候选」,供常驻巡检与人工决策。
439
+ 与 `diagnose()`(故障体检)刻意分开:这里盘的是**演化工作量**,不是故障——
440
+ 候补多不代表库有毛病,故 `ok` 恒 True、severity 恒 info。
441
+
442
+ `importance_drift` 复用 `weights.recalc(apply=False)`(纯数学,不读文件);
443
+ `ccg_backlog` 用索引代理指标。二者都不碰节点内容。
444
+ """
445
+ nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
446
+ if layer:
447
+ nodes = {k: v for k, v in nodes.items() if v.get("layer") == layer}
448
+ from . import weights
449
+ md = weights.APPLY_DELTA if min_delta is None else float(min_delta)
450
+ imp = weights.recalc(cg, layer=layer, apply=False, min_delta=md,
451
+ dry_run_samples=top)
452
+ cb = _ccg_backlog(nodes, top)
453
+ idr = {"n": imp["changed"], "scanned": imp["nodes_scanned"],
454
+ "min_delta": imp["min_delta"],
455
+ "sample": [s["node_id"] for s in imp["samples"]],
456
+ "fix": EVOLVE_FIXES["importance_drift"]}
457
+ return {"ok": True, "action": "evolve_check", "op": "sustain",
458
+ "root": cg.root, "t": time.time(), "readonly": True, "dry_run": True,
459
+ "nodes": len(nodes), "top": top, "layer": layer,
460
+ "ccg_backlog": cb, "importance_drift": idr,
461
+ "candidates": cb["n"] + idr["n"],
462
+ "by_fix": {EVOLVE_FIXES["ccg_backlog"]: cb["n"],
463
+ EVOLVE_FIXES["importance_drift"]: idr["n"]},
464
+ "note": ("只读盘点:未写盘、未改任何节点;固化需 LLM(交人工/另批),"
465
+ "重要性重算为确定性动作(有 rollback)")}
466
+
467
+
468
+ # 生效条件:给定 cg 后只读汇总(nodes 取自 cg.index、disk 计数、refindex.check_refs),stale_temp_age 传入 _list_stale_temps;check_heartbeat / check_evolution(evolve_top) / check_provenance(provenance_top) 为真时分别追加对应 issue,返回 ok = 无 severity=="warning" 的 issue 连同 stats。
469
+ def diagnose(cg, *, name: str = "md_cg", stale_temp_age: float = STALE_TEMP_AGE,
470
+ check_heartbeat: bool = True, check_evolution: bool = True,
471
+ evolve_top: int = 5, check_provenance: bool = True,
472
+ provenance_top: int = 5) -> dict:
473
+ """只读体检:返回 issues(带 fix 名)与 stats,不改动任何文件。"""
474
+ root = cg.root
475
+ issues = []
476
+ nodes = (getattr(cg, "index", {}) or {}).get("nodes") or {}
477
+ disk = _count_node_files(root)
478
+
479
+ if len(nodes) != disk:
480
+ issues.append({"code": "index_drift", "severity": "warning",
481
+ "detail": f"索引 {len(nodes)} ≠ 磁盘 {disk}",
482
+ "fix": "rebuild_index"})
483
+ orphans = [nid for nid, e in nodes.items()
484
+ if e.get("path")
485
+ and not os.path.exists(os.path.join(root, e["path"]))]
486
+ if orphans:
487
+ issues.append({"code": "index_orphan", "severity": "warning",
488
+ "detail": f"{len(orphans)} 条索引指向不存在的文件",
489
+ "sample": orphans[:5], "fix": "rebuild_index"})
490
+
491
+ shards = _index_log_shards(root)
492
+ if shards:
493
+ issues.append({"code": "index_log_backlog", "severity": "info",
494
+ "detail": f"{len(shards)} 个索引增量分片未合并",
495
+ "fix": "flush_index"})
496
+
497
+ temps = _list_stale_temps(root, stale_temp_age)
498
+ if temps:
499
+ issues.append({"code": "stale_temps", "severity": "info",
500
+ "detail": f"{len(temps)} 个陈旧临时文件",
501
+ "sample": [os.path.basename(p) for p in temps[:5]],
502
+ "fix": "sweep_temps"})
503
+
504
+ half = _half_line_logs(root)
505
+ if half:
506
+ issues.append({"code": "half_line_logs", "severity": "warning",
507
+ "detail": f"{len(half)} 个日志末尾半截行",
508
+ "sample": [os.path.basename(p) for p in half[:5]],
509
+ "fix": "seal_half_lines"})
510
+
511
+ locked = _locked_nodes(cg)
512
+ if locked:
513
+ issues.append({"code": "locked_nodes", "severity": "info",
514
+ "detail": f"{locked} 个私有节点密文不可解(缺密钥)",
515
+ "fix": None}) # 权限事实,不自愈
516
+
517
+ # ref 漂移 / 悬空(R3):索引是派生物,源变了就报 stale,源没了就报 dangling。
518
+ # 只读、不抛;修复动作是重跑 index_code / index_doc(rebuild_refs)。
519
+ from . import refindex
520
+ refs = refindex.check_refs(cg, ledger=refindex.Ledger(root))
521
+ if refs["stale"]:
522
+ issues.append({"code": "ref_stale", "severity": "warning",
523
+ "detail": f"{len(refs['stale'])} 个 ref 漂移(源文件已改动)",
524
+ "sample": [r.get("path") for r in refs["stale"][:5]],
525
+ "fix": "rebuild_refs"})
526
+ if refs["dangling"]:
527
+ issues.append({"code": "ref_dangling", "severity": "warning",
528
+ "detail": f"{len(refs['dangling'])} 个 ref 悬空(源文件已删除)",
529
+ "sample": [r.get("path") for r in refs["dangling"][:5]],
530
+ "fix": "rebuild_refs"})
531
+ if refs.get("truncated"):
532
+ issues.append({"code": "ref_check_truncated", "severity": "info",
533
+ "detail": f"ref 巡检只覆盖前 {refs['max_nodes']} 个节点,结果不完整",
534
+ "fix": None}) # 覆盖缺口,显式说出来而非静默
535
+
536
+ if check_heartbeat:
537
+ st = read_stamp(name)
538
+ state = (judge(st["age"], task_running=bool(st.get("task_running")))
539
+ if st else "absent")
540
+ if state != "ok":
541
+ issues.append({"code": "heartbeat_" + state, "severity": "info",
542
+ "detail": f"本机心跳状态:{state}", "fix": "beat"})
543
+
544
+ # ---- 演化巡检(G7):盘的是「该做多少事」,不是「库有毛病」,
545
+ # 故 severity 恒 info(不影响 ok),且全程只读、零读节点文件。
546
+ evolve = None
547
+ if check_evolution:
548
+ evolve = evolution_candidates(cg, top=evolve_top)
549
+ cb, idr = evolve["ccg_backlog"], evolve["importance_drift"]
550
+ if cb["n"]:
551
+ issues.append({"code": "ccg_backlog", "severity": "info",
552
+ "detail": (f"{cb['n']} 个固化候补"
553
+ f"(索引代理:无验证基底 {cb['no_basis']}"
554
+ f" / 无负条件 {cb['no_neg']})"),
555
+ "sample": cb["sample"], "fix": cb["fix"],
556
+ "proxy": True})
557
+ if idr["n"]:
558
+ issues.append({"code": "importance_drift", "severity": "info",
559
+ "detail": (f"{idr['n']} 个节点结构重要性偏离 "
560
+ f"≥{idr['min_delta']}(可重算)"),
561
+ "sample": idr["sample"], "fix": idr["fix"]})
562
+
563
+ # ---- 派生溯源巡检(G8):只读检出悬空派生边(端点已不在索引内)。
564
+ # **无自动修复**:删边等于篡改演进血缘,只报告、由人处置;
565
+ # 故 severity 恒 info(不影响 ok、不触发自愈),零读节点文件。
566
+ prov = None
567
+ if check_provenance:
568
+ from . import provenance as _pv
569
+ prov = _pv.check(cg, limit=provenance_top)
570
+ if prov["dangling_count"]:
571
+ issues.append({"code": "provenance_dangling", "severity": "info",
572
+ "detail": (f"{prov['dangling_count']} 条派生边悬空"
573
+ f"({prov['edges']} 条边中,端点不在索引内)"),
574
+ "sample": [f"{r['child']}->{r['parent']}"
575
+ for r in prov["dangling"]],
576
+ "fix": None}) # 关系事实:只检出,不自动删边
577
+
578
+ return {"ok": not any(i["severity"] == "warning" for i in issues),
579
+ "root": root, "issues": issues, "t": time.time(),
580
+ "evolve": evolve, "provenance": prov,
581
+ "stats": {"nodes_indexed": len(nodes), "nodes_on_disk": disk,
582
+ "index_log_shards": len(shards), "stale_temps": len(temps),
583
+ "half_line_logs": len(half), "locked_nodes": locked,
584
+ "ref_checked": refs["checked"],
585
+ "ref_stale": len(refs["stale"]),
586
+ "ref_dangling": len(refs["dangling"]),
587
+ "provenance_edges": (prov["edges"] if prov else 0),
588
+ "provenance_dangling":
589
+ (prov["dangling_count"] if prov else 0),
590
+ "evolve_candidates":
591
+ (evolve["candidates"] if evolve else 0)}}
592
+
593
+
594
+ # --------------------------------------------------------------------------
595
+ # 自愈
596
+ # --------------------------------------------------------------------------
597
+
598
+ # 生效条件:path 必填,以 'ab' 模式向该路径追加写入单个换行 b'\n',无返回值。
599
+ def _seal_half_line(path: str):
600
+ with open(path, "ab") as f:
601
+ f.write(b"\n")
602
+
603
+
604
+ # 生效条件:root/op/action 必填,向 root/SUSTAIN_LOG(模块常量)追加一行含 t/op/action/detail(截断 200)/pid 的 JSONL,无返回值。
605
+ def _audit(root: str, op: str, action: str, detail: str = ""):
606
+ append_jsonl(os.path.join(root, SUSTAIN_LOG),
607
+ {"t": time.time(), "op": op, "action": action,
608
+ "detail": str(detail)[:200], "pid": os.getpid()})
609
+
610
+
611
+ # 生效条件:按 diagnose(cg, name=name, stale_temp_age=stale_temp_age) 的 issues code 集合分派——命中 index_drift/index_orphan 重建索引、ref_stale/ref_dangling 按 ref 重建源索引、index_log_backlog 合并索引分片、stale_temps 清理陈旧临时文件、half_line_logs 修补半截日志;ccg_backlog 仅 allow_evolve=True 且 reflect_fn 非 None 时才 consolidate(否则记 needs_llm),importance_drift 仅 allow_evolve=True 时才重算重要性(否则记 evolve_disabled);dry_run=True 时各动作只记入 actions 不落盘,返回含 after["ok"]、dry_run、actions、before/after 的 stats 与 t 的 dict;
612
+ def heal(cg, *, name: str = "md_cg", dry_run: bool = False,
613
+ stale_temp_age: float = STALE_TEMP_AGE,
614
+ allow_evolve: bool = False, reflect_fn=None, verify_fn=None) -> dict:
615
+ """按诊断结果修复派生物。dry_run=True 时只列动作、不落盘。
616
+
617
+ 演化类动作(G7)默认**不动**,须显式 `allow_evolve=True` 才放行,且只放行
618
+ **确定性**动作(重要性重算,有 rollback);依赖 LLM 的固化永不自动跑。
619
+ """
620
+ before = diagnose(cg, name=name, stale_temp_age=stale_temp_age)
621
+ codes = {i["code"] for i in before["issues"]}
622
+ root = cg.root
623
+ actions = []
624
+
625
+ # 生效条件:闭包 dry_run 为真时向 actions 追加 {"code": code, "detail": detail, "applied": False} 并返回;否则调用 fn(),成功追加 applied=True/ok=True,抛异常时追加 applied=True/ok=False 与 error,最后执行 _audit(root, "heal", code, detail);
626
+ def act(code: str, detail: str, fn):
627
+ if dry_run:
628
+ actions.append({"code": code, "detail": detail, "applied": False})
629
+ return
630
+ try:
631
+ fn()
632
+ actions.append({"code": code, "detail": detail,
633
+ "applied": True, "ok": True})
634
+ except Exception as e: # 自愈失败不能拖垮进程
635
+ actions.append({"code": code, "detail": detail, "applied": True,
636
+ "ok": False, "error": f"{type(e).__name__}: {e}"})
637
+ _audit(root, "heal", code, detail)
638
+
639
+ if "index_drift" in codes or "index_orphan" in codes:
640
+ act("rebuild_index", "重建索引(漂移 / 孤儿)", cg.rebuild_index)
641
+ if "ref_stale" in codes or "ref_dangling" in codes:
642
+ from . import refindex as _ri
643
+ _led = _ri.Ledger(root)
644
+ act("rebuild_refs", "按 ref 重建源索引(修复漂移;悬空需人工处置)",
645
+ lambda: _ri.rebuild(cg, ledger=_led))
646
+ if "index_log_backlog" in codes:
647
+ act("flush_index", "合并索引增量分片", cg.flush)
648
+ if "stale_temps" in codes:
649
+ paths = _list_stale_temps(root, stale_temp_age)
650
+
651
+ # 生效条件:对闭包变量 paths 中每个路径尝试 os.remove(p),单个路径的 OSError 被吞掉后继续处理后续路径;
652
+ def _sweep():
653
+ for p in paths:
654
+ try:
655
+ os.remove(p)
656
+ except OSError:
657
+ pass
658
+ act("sweep_temps", f"清理 {len(paths)} 个陈旧临时文件", _sweep)
659
+ if "half_line_logs" in codes:
660
+ paths = _half_line_logs(root)
661
+ act("seal_half_lines", f"修补 {len(paths)} 个半截日志行",
662
+ lambda: [_seal_half_line(p) for p in paths])
663
+
664
+ # ---- 演化类修复(G7):默认关闭,且只放行确定性动作 ----
665
+ if "ccg_backlog" in codes:
666
+ det = next((i for i in before["issues"] if i["code"] == "ccg_backlog"), {})
667
+ detail = f"{det.get('detail', '固化候补')};固化需 LLM 反思/验证"
668
+ if allow_evolve and reflect_fn is not None:
669
+ from . import consolidate as _cd
670
+
671
+ # 生效条件:调用时返回 _cd.consolidate(cg.root, apply=True, reflect_fn=reflect_fn, verify_fn=verify_fn);
672
+ def _consolidate():
673
+ return _cd.consolidate(cg.root, apply=True,
674
+ reflect_fn=reflect_fn,
675
+ verify_fn=verify_fn)
676
+ act("consolidate_run", detail, _consolidate)
677
+ else:
678
+ actions.append({"code": "consolidate_run", "detail": detail,
679
+ "applied": False, "reason": "needs_llm"})
680
+ if "importance_drift" in codes:
681
+ if allow_evolve:
682
+ from . import weights
683
+
684
+ # 生效条件:调用时返回 weights.recalc(cg, apply=True, actor="sustain_evolve");
685
+ def _importance():
686
+ return weights.recalc(cg, apply=True, actor="sustain_evolve")
687
+ act("importance", "重算结构重要性(确定性;可 rollback)", _importance)
688
+ else:
689
+ actions.append({"code": "importance",
690
+ "detail": "重算结构重要性(确定性动作)",
691
+ "applied": False, "reason": "evolve_disabled"})
692
+
693
+ after = (before if dry_run
694
+ else diagnose(cg, name=name, stale_temp_age=stale_temp_age))
695
+ return {"ok": after["ok"], "dry_run": dry_run, "actions": actions,
696
+ "before": before["stats"], "after": after["stats"],
697
+ "t": time.time()}
698
+
699
+
700
+ # --------------------------------------------------------------------------
701
+ # 会话水位(重启续接)
702
+ # --------------------------------------------------------------------------
703
+
704
+ # 生效条件:以 root 实例化后账本固定指向 os.path.join(root, LEDGER_FILE),其 _load/_save/note/resume_point/sessions/summary 均以该路径为唯一读写对象;
705
+ class SessionLedger:
706
+ """会话水位账本:<root>/_sessions.json。
707
+
708
+ 与 sources.Ingestor 的水位互补:Ingestor 记「某个源读到哪」,
709
+ 这里记「某个会话发生过什么」——重启后按会话给出续接点。
710
+ """
711
+
712
+ # 生效条件:传入 root 时置 self.root=root 且 self.path=os.path.join(root, LEDGER_FILE),不做其他校验;
713
+ def __init__(self, root: str):
714
+ self.root = root
715
+ self.path = os.path.join(root, LEDGER_FILE)
716
+
717
+ # 生效条件:self.path(root/LEDGER_FILE)可读且 json 结果为 dict、且 d.get("sessions") 也是 dict 时返回该 d;self.path 不可读、json.load 抛 ValueError/OSError、或 d 非 dict / sessions 非 dict 时返回 {"schema": 1, "sessions": {}};
718
+ def _load(self):
719
+ if os.path.exists(self.path):
720
+ try:
721
+ with open(self.path, encoding="utf-8") as f:
722
+ d = json.load(f)
723
+ if isinstance(d, dict) and isinstance(d.get("sessions"), dict):
724
+ return d
725
+ except (ValueError, OSError):
726
+ pass
727
+ return {"schema": 1, "sessions": {}}
728
+
729
+ # 生效条件:传入 d 时执行 atomic_write(self.path, json.dumps(d, ensure_ascii=False, indent=1)),自身无返回值;
730
+ def _save(self, d):
731
+ atomic_write(self.path, json.dumps(d, ensure_ascii=False, indent=1))
732
+
733
+ # 生效条件:以 session 为键 setdefault 会话记录后写 events=int(s.get("events") or 0)+int(n)(n 默认 1,n=0 时计数不变),t 非 None 时写 last_t 且 first_t 为空时一并写入,seq 非 None 时写 last_seq,actor 为真值时写 actor,最后写 updated_at 并 _save,返回 dict(s, session=session);
734
+ def note(self, session: str, *, t=None, seq=None, n: int = 1,
735
+ actor: str = None) -> dict:
736
+ """记一笔会话活动(事件计数 + 最后 (t, seq) 水位)。"""
737
+ d = self._load()
738
+ s = d["sessions"].setdefault(
739
+ session, {"events": 0, "first_t": None, "last_t": None})
740
+ s["events"] = int(s.get("events") or 0) + int(n)
741
+ if t is not None:
742
+ s["last_t"] = float(t)
743
+ if s.get("first_t") is None:
744
+ s["first_t"] = float(t)
745
+ if seq is not None:
746
+ s["last_seq"] = seq
747
+ if actor:
748
+ s["actor"] = actor
749
+ s["updated_at"] = time.time()
750
+ self._save(d)
751
+ return dict(s, session=session)
752
+
753
+ # 生效条件:_load()["sessions"].get(session) 为假值(键缺失或值为空 dict)时返回 {"session": session, "resume": None, "events": 0};否则返回 {"session": session, "events": s.get("events", 0), "resume": {"t": s.get("last_t"), "seq": s.get("last_seq")}, "last_t": s.get("last_t"), "actor": s.get("actor")};
754
+ def resume_point(self, session: str) -> dict:
755
+ """重启续接点:(t, seq) 之后的事件才是新的。"""
756
+ s = self._load()["sessions"].get(session)
757
+ if not s:
758
+ return {"session": session, "resume": None, "events": 0}
759
+ return {"session": session, "events": s.get("events", 0),
760
+ "resume": {"t": s.get("last_t"), "seq": s.get("last_seq")},
761
+ "last_t": s.get("last_t"), "actor": s.get("actor")}
762
+
763
+ # 生效条件:无参调用时返回 dict(self._load()["sessions"]) 的浅拷贝,账本缺失/损坏时 _load 回落默认值故此处为 {};
764
+ def sessions(self) -> dict:
765
+ return dict(self._load()["sessions"])
766
+
767
+ # 生效条件:取 d=_load()["sessions"] 后返回 {"sessions": len(d), "events": sum(int(v.get("events") or 0)), "latest": max(v.get("last_t") or 0) if d else None},d 为空时 latest 为 None;
768
+ def summary(self) -> dict:
769
+ d = self._load()["sessions"]
770
+ return {"sessions": len(d),
771
+ "events": sum(int(v.get("events") or 0) for v in d.values()),
772
+ "latest": max((v.get("last_t") or 0) for v in d.values())
773
+ if d else None}
774
+
775
+
776
+ # 生效条件:os.path.exists(os.path.join(cg.root, "_sources.json")) 为真且 json.load 成功时返回 dict((d or {}).get("sources") or {})(d 或 sources 为假值即回落 {});该路径不可读或 json.load 抛 ValueError/OSError 时返回 {};
777
+ def watermarks(cg) -> dict:
778
+ """源视角水位快照(读 sources.Ingestor 的 _sources.json)。"""
779
+ p = os.path.join(cg.root, "_sources.json")
780
+ if not os.path.exists(p):
781
+ return {}
782
+ try:
783
+ with open(p, encoding="utf-8") as f:
784
+ d = json.load(f)
785
+ except (ValueError, OSError):
786
+ return {}
787
+ return dict((d or {}).get("sources") or {})
788
+
789
+
790
+ # --------------------------------------------------------------------------
791
+ # 常驻循环
792
+ # --------------------------------------------------------------------------
793
+
794
+ # 生效条件:传入 cg 即构造实例并把 self.cg 指向它,name/beat_interval/heal_interval/auto_heal/scrub_interval/auto_scrub/evolve_interval/auto_evolve/tidy_interval/auto_tidy 用各默认值(DEFAULT_* 与 False/True)经 float()/bool() 落为 self 属性,ledger 为假值(默认 None)时回落 SessionLedger(cg.root),d 经 net_dir(d) 赋值,其余运行态字段初始化为 False/None/空列表/空 Event/Lock
795
+ class SustainLoop:
796
+ """常驻自维持循环:后台线程周期心跳 + 周期巡检 + 必要时自愈。
797
+
798
+ daemon 线程,进程退出即消失;`stop()` 会清掉自己的心跳戳,
799
+ 让对端立刻看到「正常下线」而不是「失联」。
800
+ """
801
+
802
+ # 生效条件:传入 cg 时按 name 与各 DEFAULT_* 默认值初始化——self.d=net_dir(d)(d 假值时回落 MDCG_SUSTAIN_DIR/~/ .mdcg/sustain)、self.ledger=ledger or SessionLedger(cg.root)(ledger 假值时新建),beat/heal/scrub/evolve/tidy 间隔 float() 化、auto_heal/auto_scrub/auto_evolve/auto_tidy bool() 化后存为实例属性;
803
+ def __init__(self, cg, name: str = "md_cg", *,
804
+ beat_interval: float = DEFAULT_BEAT_INTERVAL,
805
+ heal_interval: float = DEFAULT_HEAL_INTERVAL,
806
+ auto_heal: bool = True, d: str = None,
807
+ ledger: SessionLedger = None,
808
+ scrub_interval: float = DEFAULT_SCRUB_INTERVAL,
809
+ auto_scrub: bool = False,
810
+ evolve_interval: float = DEFAULT_EVOLVE_INTERVAL,
811
+ auto_evolve: bool = False,
812
+ tidy_interval: float = DEFAULT_TIDY_INTERVAL,
813
+ auto_tidy: bool = False):
814
+ self.cg = cg
815
+ self.name = name
816
+ self.beat_interval = float(beat_interval)
817
+ self.heal_interval = float(heal_interval)
818
+ self.auto_heal = bool(auto_heal)
819
+ self.d = net_dir(d)
820
+ self.ledger = ledger or SessionLedger(cg.root)
821
+ self.scrub_interval = float(scrub_interval)
822
+ self.auto_scrub = bool(auto_scrub)
823
+ self.evolve_interval = float(evolve_interval)
824
+ self.auto_evolve = bool(auto_evolve)
825
+ self.tidy_interval = float(tidy_interval)
826
+ self.auto_tidy = bool(auto_tidy)
827
+ self.task_running = False
828
+ self.beats = 0
829
+ self.last_beat = None
830
+ self.last_diagnose = None
831
+ self.heals = []
832
+ self.last_scrub = None
833
+ self.scrubs = []
834
+ self.last_evolve = None
835
+ self.evolves = []
836
+ self.last_tidy = None
837
+ self.tidys = []
838
+ # 数据健康不变量断言集结论(Pi⑤):与 tidy 同节奏,只取结论不落盘
839
+ self.last_conformance = None
840
+ self._started_at = None
841
+ self._th = None
842
+ self._stop = threading.Event()
843
+ self._lock = threading.Lock()
844
+
845
+ # ---- 心跳 ----
846
+
847
+ # 生效条件:task_running 非 None 时先置 self.task_running=bool(task_running),再 write_stamp(self.name, self.d, task_running=self.task_running, root=self.cg.root, uptime=... if self._started_at else 0.0),beats 自增并记 last_beat=rec["ts"],返回该 rec;
848
+ def beat(self, task_running: bool = None) -> dict:
849
+ if task_running is not None:
850
+ self.task_running = bool(task_running)
851
+ rec = write_stamp(
852
+ self.name, self.d, task_running=self.task_running, root=self.cg.root,
853
+ uptime=round(time.time() - self._started_at, 3)
854
+ if self._started_at else 0.0)
855
+ self.beats += 1
856
+ self.last_beat = rec["ts"]
857
+ return rec
858
+
859
+ # ---- 生命周期 ----
860
+
861
+ # 生效条件:不适用(无必需形参与模块级常量)
862
+ def start(self):
863
+ if self._th is not None and self._th.is_alive():
864
+ return self
865
+ self._started_at = time.time()
866
+ self._stop.clear()
867
+ self.beat()
868
+ self._th = threading.Thread(target=self._run, name="mdcg-sustain",
869
+ daemon=True)
870
+ self._th.start()
871
+ return self
872
+
873
+ # 生效条件:调用时置 _stop 事件,self._th 非 None 时 join(timeout)(默认 3.0)后置为 None,随后 clear_stamp(self.name, self.d),返回 self;
874
+ def stop(self, timeout: float = 3.0):
875
+ self._stop.set()
876
+ if self._th is not None:
877
+ self._th.join(timeout)
878
+ self._th = None
879
+ clear_stamp(self.name, self.d)
880
+ return self
881
+
882
+ # 生效条件:self._stop 未置位期间轮询,按 beat_interval/heal_interval/scrub_interval/evolve_interval/tidy_interval 到期分别执行 beat 与 _tick_heal/_tick_scrub/_tick_evolve/_tick_tidy,各 tick 抛出的异常被吞掉不中断循环,末尾以 _stop.wait(_POLL) 休眠;
883
+ def _run(self):
884
+ next_beat = time.time() + self.beat_interval
885
+ next_heal = time.time() + self.heal_interval
886
+ next_scrub = time.time() + self.scrub_interval
887
+ next_evolve = time.time() + self.evolve_interval
888
+ next_tidy = time.time() + self.tidy_interval
889
+ while not self._stop.is_set():
890
+ now = time.time()
891
+ if now >= next_beat:
892
+ try:
893
+ self.beat()
894
+ except Exception:
895
+ pass # 心跳失败不中断常驻
896
+ next_beat = now + self.beat_interval
897
+ if now >= next_heal:
898
+ try:
899
+ self._tick_heal()
900
+ except Exception:
901
+ pass # 巡检失败不中断常驻
902
+ next_heal = now + self.heal_interval
903
+ if now >= next_scrub:
904
+ try:
905
+ self._tick_scrub()
906
+ except Exception:
907
+ pass # 自净失败不中断常驻
908
+ next_scrub = now + self.scrub_interval
909
+ if now >= next_evolve:
910
+ try:
911
+ self._tick_evolve()
912
+ except Exception:
913
+ pass # 演化巡检失败不中断常驻
914
+ next_evolve = now + self.evolve_interval
915
+ if now >= next_tidy:
916
+ try:
917
+ self._tick_tidy()
918
+ except Exception:
919
+ pass # 整理巡检失败不中断常驻
920
+ next_tidy = now + self.tidy_interval
921
+ self._stop.wait(_POLL)
922
+
923
+ # 生效条件:以 apply=self.auto_tidy 调 writelimit.tidy_contextual(self.cg, actor="sustain_tidy"),把 t/scanned/groups/members/applied_count/auto_tidy 记入 self.last_tidy 与 tidys(仅保留最近 20 条),随后调 _tick_conformance();auto_tidy=False(默认)时只盘点不落盘;
924
+ def _tick_tidy(self):
925
+ """整理巡检(contextual 流水治理·读侧):同构组聚合 + 成员降权。
926
+
927
+ 确定性动作、永不删节点;`auto_tidy=False`(默认)只盘点不落盘。
928
+ 治理对象:单日批次流水(「批次247收官记忆」×163 那类同模板写入)
929
+ —— 写入侧限流(writelimit.check)拦增量,本巡检收敛存量。
930
+ """
931
+ from . import writelimit
932
+ r = writelimit.tidy_contextual(self.cg, apply=self.auto_tidy,
933
+ actor="sustain_tidy")
934
+ rec = {"t": r["t"], "scanned": r["scanned"], "groups": r["groups"],
935
+ "members": r["members"],
936
+ "applied_count": r.get("applied_count", 0),
937
+ "auto_tidy": self.auto_tidy}
938
+ self.last_tidy = rec
939
+ with self._lock:
940
+ self.tidys.append(rec)
941
+ self.tidys = self.tidys[-20:]
942
+ self._tick_conformance()
943
+
944
+ # 生效条件:把 conformance.report_summary(self.cg) 的结论写入 self.last_conformance;该调用抛异常时写入 {"ok": False, "verdict": "BLINDSPOT", "error": "<类型名>: <消息>"};
945
+ def _tick_conformance(self):
946
+ """数据健康不变量断言集(Pi⑤)——与 tidy 同节奏,**只取结论**。
947
+
948
+ 复用 tidy 的 6h 节奏而不新开周期:两者都是「存量数据体检」,且都是
949
+ 只读巡检。纪律:本断言集**只告警不改数据**,故 `auto_*` 在此无意义;
950
+ `check_paths=False` 跳过 1.1 万次 stat,`_audit.jsonl` 只读尾窗。
951
+ """
952
+ from . import conformance
953
+ try:
954
+ self.last_conformance = conformance.report_summary(self.cg)
955
+ except Exception as e: # noqa: BLE001
956
+ self.last_conformance = {"ok": False, "verdict": "BLINDSPOT",
957
+ "error": f"{type(e).__name__}: {e}"}
958
+
959
+ # 生效条件:恒以 evolution_candidates(self.cg) 只读盘点并记入 last_evolve 与 evolves(保留最近 20 条);仅当 self.auto_evolve 为真且 ev["importance_drift"]["n"] 为真时才额外执行 weights.recalc(self.cg, apply=True, actor="sustain_evolve"),其异常写入 rec["applied"];
960
+ def _tick_evolve(self):
961
+ """演化巡检(G7):盘点固化/重要性候选 —— 让「有能力」变成「有驱动」。
962
+
963
+ 恒只读盘点并记账;`auto_evolve=True` 时才额外落盘**确定性**动作
964
+ (仅重要性重算,可 rollback)。固化依赖 LLM,巡检只报候补量、交人工。
965
+ """
966
+ ev = evolution_candidates(self.cg)
967
+ rec = {"t": ev["t"], "candidates": ev["candidates"],
968
+ "ccg_backlog": ev["ccg_backlog"]["n"],
969
+ "importance_drift": ev["importance_drift"]["n"],
970
+ "auto_evolve": self.auto_evolve, "applied": []}
971
+ if self.auto_evolve and ev["importance_drift"]["n"]:
972
+ from . import weights
973
+ try:
974
+ r = weights.recalc(self.cg, apply=True, actor="sustain_evolve")
975
+ rec["applied"].append({"fix": "importance",
976
+ "written": r["written"],
977
+ "batch": r["batch"]})
978
+ except Exception as e: # 演化失败不能拖垮常驻
979
+ rec["applied"].append({"fix": "importance",
980
+ "error": f"{type(e).__name__}: {e}"})
981
+ self.last_evolve = rec
982
+ with self._lock:
983
+ self.evolves.append(rec)
984
+ self.evolves = self.evolves[-20:]
985
+
986
+ # 生效条件:以 dry_run=not self.auto_scrub 调 scrub.sweep(self.cg),把 t/ok/n_issues/n_high_medium/applied/dry_run 记入 last_scrub 与 scrubs(保留最近 20 条);auto_scrub=False(默认)时 dry_run=True 只读巡检;
987
+ def _tick_scrub(self):
988
+ """记忆自净:抽查 → 联想 → 去污染 → 校准偏差。
989
+
990
+ `auto_scrub=False`(默认)时只做只读巡检并记账,不动任何节点;
991
+ 开启后才执行去污染(仍只做可逆动作、永不删节点)。
992
+ """
993
+ from . import scrub
994
+ rep = scrub.sweep(self.cg, dry_run=not self.auto_scrub)
995
+ rec = {"t": rep["t"], "ok": rep["ok"],
996
+ "n_issues": rep["audit"]["n_issues"],
997
+ "n_high_medium": rep["n_high_medium"],
998
+ "applied": rep["decontaminate"]["applied"],
999
+ "dry_run": rep["dry_run"]}
1000
+ self.last_scrub = rec
1001
+ with self._lock:
1002
+ self.scrubs.append(rec)
1003
+ self.scrubs = self.scrubs[-20:]
1004
+
1005
+ # 生效条件:先 diagnose(self.cg, name=self.name) 并将 ok 与 issues code 记入 last_diagnose;仅当 self.auto_heal 为真且 rep["ok"] 为假时才调 heal(self.cg, name=self.name),其 actions 非空时把各 action 的 code 记入 heals(保留最近 20 条);
1006
+ def _tick_heal(self):
1007
+ rep = diagnose(self.cg, name=self.name)
1008
+ self.last_diagnose = {"t": time.time(), "ok": rep["ok"],
1009
+ "issues": [i["code"] for i in rep["issues"]]}
1010
+ if not (self.auto_heal and not rep["ok"]):
1011
+ return
1012
+ res = heal(self.cg, name=self.name)
1013
+ if res["actions"]:
1014
+ with self._lock:
1015
+ self.heals.append({"t": res["t"],
1016
+ "actions": [a["code"] for a in res["actions"]]})
1017
+ self.heals = self.heals[-20:]
1018
+
1019
+ # ---- 状态 ----
1020
+
1021
+ # 生效条件:以 read_stamp(self.name, self.d) 判定 state(有戳走 judge(age, interval=self.beat_interval, task_running=...),无戳为 "stopped"),返回含 name/running/pid/uptime(无 _started_at 时为 0.0)/beats/last_beat/各 interval 与 auto_* 开关/heals[-5:]/evolves[-5:]/tidys[-5:]/peers(self.d)/ledger.summary() 的 dict;
1022
+ def status(self) -> dict:
1023
+ st = read_stamp(self.name, self.d)
1024
+ state = (judge(st["age"], interval=self.beat_interval,
1025
+ task_running=bool(st.get("task_running")))
1026
+ if st else "stopped")
1027
+ return {"name": self.name, "running": bool(self._th
1028
+ and self._th.is_alive()),
1029
+ "pid": os.getpid(),
1030
+ "uptime": round(time.time() - self._started_at, 3)
1031
+ if self._started_at else 0.0,
1032
+ "beats": self.beats, "last_beat": self.last_beat,
1033
+ "beat_interval": self.beat_interval,
1034
+ "heal_interval": self.heal_interval,
1035
+ "auto_heal": self.auto_heal, "state": state,
1036
+ "task_running": self.task_running,
1037
+ "last_diagnose": self.last_diagnose,
1038
+ "heals": self.heals[-5:],
1039
+ "scrub_interval": self.scrub_interval,
1040
+ "auto_scrub": self.auto_scrub,
1041
+ "last_scrub": self.last_scrub,
1042
+ "evolve_interval": self.evolve_interval,
1043
+ "auto_evolve": self.auto_evolve,
1044
+ "last_evolve": self.last_evolve,
1045
+ "evolves": self.evolves[-5:],
1046
+ "last_tidy": self.last_tidy,
1047
+ "tidys": self.tidys[-5:],
1048
+ "last_conformance": self.last_conformance,
1049
+ "peers": peers(self.d),
1050
+ "sessions": self.ledger.summary()}
1051
+
1052
+
1053
+ _LOOPS = {}
1054
+ _LOOPS_LOCK = threading.Lock()
1055
+
1056
+
1057
+ # 生效条件:cg 必填,以 (os.path.abspath(cg.root), name) 为键在模块级 _LOOPS 中复用已有实例或新建 SustainLoop 并返回该实例。
1058
+ def ensure_loop(cg, name: str = "md_cg", **kw) -> SustainLoop:
1059
+ """按 (root, name) 复用同一个常驻循环(避免重复线程 / 重复戳)。"""
1060
+ key = (os.path.abspath(cg.root), name)
1061
+ with _LOOPS_LOCK:
1062
+ lp = _LOOPS.get(key)
1063
+ if lp is None:
1064
+ lp = SustainLoop(cg, name=name, **kw)
1065
+ _LOOPS[key] = lp
1066
+ return lp
1067
+
1068
+
1069
+ # 生效条件:cg 必填,返回 _LOOPS[(os.path.abspath(cg.root), name)],该键未登记时返回 None。
1070
+ def get_loop(cg, name: str = "md_cg"):
1071
+ return _LOOPS.get((os.path.abspath(cg.root), name))
1072
+
1073
+
1074
+ # 生效条件:模块级 _LOOPS 非空时清空该表并对每个循环调用 stop()(异常被吞),无返回值。
1075
+ def stop_all():
1076
+ """测试 / 进程退出用:停掉本进程创建的所有常驻循环。"""
1077
+ with _LOOPS_LOCK:
1078
+ loops = list(_LOOPS.values())
1079
+ _LOOPS.clear()
1080
+ for lp in loops:
1081
+ try:
1082
+ lp.stop()
1083
+ except Exception:
1084
+ pass
1085
+
1086
+
1087
+ # 生效条件:以 read_stamp(name) 有戳时返回 heartbeat={'age': round(st["age"],1), 'state': judge(...), 'pid', 'task_running'}、无戳时 {'state':'absent'},loop 取 get_loop(cg, name) 有值时给 running/beats/state、无值时 {'running': False},并附带 SessionLedger(cg.root).summary()、scrub_summary(cg)、evolve_summary(cg)、provenance_summary(cg);
1088
+ def summary(cg, name: str = "md_cg") -> dict:
1089
+ """并入 health_os 的轻量摘要(只读戳与计数,不做巡检)。"""
1090
+ st = read_stamp(name)
1091
+ lp = get_loop(cg, name)
1092
+ return {
1093
+ "heartbeat": ({"age": round(st["age"], 1),
1094
+ "state": judge(st["age"],
1095
+ task_running=bool(st.get("task_running"))),
1096
+ "pid": st.get("pid"),
1097
+ "task_running": bool(st.get("task_running"))}
1098
+ if st else {"state": "absent"}),
1099
+ "loop": ({"running": bool(lp._th and lp._th.is_alive()),
1100
+ "beats": lp.beats, "state": lp.status()["state"]}
1101
+ if lp else {"running": False}),
1102
+ "sessions": SessionLedger(cg.root).summary(),
1103
+ "scrub": scrub_summary(cg),
1104
+ "evolve": evolve_summary(cg),
1105
+ "provenance": provenance_summary(cg),
1106
+ }
1107
+
1108
+
1109
+ # 生效条件:cg 必填,可导入 provenance 且其 summary(cg) 调用成功时返回该摘要,异常时返回 {}。
1110
+ def provenance_summary(cg) -> dict:
1111
+ """派生溯源摘要(G8,只读;失败不抛,避免拖垮 health_os)。"""
1112
+ try:
1113
+ from . import provenance as _pv
1114
+ return _pv.summary(cg)
1115
+ except Exception: # noqa: BLE001
1116
+ return {}
1117
+
1118
+
1119
+ # 生效条件:cg 必填,evolution_candidates(cg, top=3) 成功时返回 candidates/ccg_backlog/importance_drift 计数摘要,异常时返回 {}。
1120
+ def evolve_summary(cg) -> dict:
1121
+ """演化巡检摘要(只读;失败不抛,避免拖垮 health_os)。"""
1122
+ try:
1123
+ ev = evolution_candidates(cg, top=3)
1124
+ return {"candidates": ev["candidates"],
1125
+ "ccg_backlog": ev["ccg_backlog"]["n"],
1126
+ "importance_drift": ev["importance_drift"]["n"],
1127
+ "proxy": True}
1128
+ except Exception: # noqa: BLE001
1129
+ return {}
1130
+
1131
+
1132
+ # 生效条件:cg 必填,可导入 scrub 且其 summary(cg) 调用成功时返回该摘要,异常时返回 {}。
1133
+ def scrub_summary(cg) -> dict:
1134
+ """记忆自净摘要(惰性导入,避免模块加载顺序耦合)。"""
1135
+ try:
1136
+ from . import scrub
1137
+ return scrub.summary(cg)
1138
+ except Exception: # noqa: BLE001
1139
1139
  return {}