dsh-biocore 0.2.0 → 0.3.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 (245) hide show
  1. package/README.md +11 -0
  2. package/lib/core-worker.js +268 -0
  3. package/lib/index.js +160 -70
  4. package/lib/modules/autonomy.js +125 -0
  5. package/lib/modules/cache-stats.js +48 -0
  6. package/lib/modules/core-turn.js +24 -6
  7. package/lib/modules/expression-inject.js +0 -10
  8. package/lib/modules/expression-refresh.js +0 -9
  9. package/lib/modules/memory-capture.js +2 -1
  10. package/lib/modules/memory-inject.js +50 -5
  11. package/lib/modules/memory-tools.js +7 -4
  12. package/lib/modules/python-env.js +46 -0
  13. package/lib/modules/safety-gate.js +0 -3
  14. package/lib/modules/series.js +54 -0
  15. package/lib/modules/switch.js +16 -0
  16. package/lib/py-bridge.js +81 -15
  17. package/package.json +1 -1
  18. package/python/biocore_memory/core/config.py +138 -127
  19. package/python/biocore_memory/engine.py +2653 -2476
  20. package/python/biocore_memory/recall/embedding.py +129 -0
  21. package/python/biocore_memory/recall/feedback.py +202 -0
  22. package/python/biocore_model/preference_drift.py +255 -0
  23. package/python/biocore_model/tests/__init__.py +1 -0
  24. package/python/biocore_model/tests/core_mode_ui_check.mjs +63 -0
  25. package/python/biocore_model/tests/core_status_check.mjs +29 -0
  26. package/python/biocore_model/tests/dom_safe_check.mjs +22 -0
  27. package/python/biocore_model/tests/leak_samples_run.py +18 -0
  28. package/python/biocore_model/tests/orb_ring_check.mjs +20 -0
  29. package/python/biocore_model/tests/panel_html_check.mjs +63 -0
  30. package/python/biocore_model/tests/persona_flow_check.mjs +17 -0
  31. package/python/biocore_model/tests/render_guard_check.mjs +19 -0
  32. package/python/biocore_model/tests/switch_groups_check.mjs +94 -0
  33. package/python/biocore_model/tests/tabs_state_check.mjs +24 -0
  34. package/python/biocore_model/tests/test_affective_labor.py +102 -0
  35. package/python/biocore_model/tests/test_appraisal.py +137 -0
  36. package/python/biocore_model/tests/test_appraisal_action.py +55 -0
  37. package/python/biocore_model/tests/test_arch_convergence.py +35 -0
  38. package/python/biocore_model/tests/test_attachment_behavior.py +95 -0
  39. package/python/biocore_model/tests/test_attachment_regulation.py +63 -0
  40. package/python/biocore_model/tests/test_attachment_state.py +29 -0
  41. package/python/biocore_model/tests/test_attribution.py +56 -0
  42. package/python/biocore_model/tests/test_attributor.py +108 -0
  43. package/python/biocore_model/tests/test_autonomic.py +69 -0
  44. package/python/biocore_model/tests/test_autonomy_accumulator.py +84 -0
  45. package/python/biocore_model/tests/test_autonomy_affect.py +62 -0
  46. package/python/biocore_model/tests/test_autonomy_blame.py +44 -0
  47. package/python/biocore_model/tests/test_autonomy_caps.py +29 -0
  48. package/python/biocore_model/tests/test_autonomy_check.py +40 -0
  49. package/python/biocore_model/tests/test_autonomy_clingy.py +41 -0
  50. package/python/biocore_model/tests/test_autonomy_cooldown_selfcontact.py +138 -0
  51. package/python/biocore_model/tests/test_autonomy_frustration.py +42 -0
  52. package/python/biocore_model/tests/test_autonomy_hourly_limit.py +46 -0
  53. package/python/biocore_model/tests/test_autonomy_loop.py +64 -0
  54. package/python/biocore_model/tests/test_autonomy_mode.py +37 -0
  55. package/python/biocore_model/tests/test_autonomy_model.py +60 -0
  56. package/python/biocore_model/tests/test_autonomy_model_v2.py +68 -0
  57. package/python/biocore_model/tests/test_autonomy_module_text.py +104 -0
  58. package/python/biocore_model/tests/test_autonomy_params.py +163 -0
  59. package/python/biocore_model/tests/test_autonomy_reason.py +34 -0
  60. package/python/biocore_model/tests/test_autonomy_self.py +153 -0
  61. package/python/biocore_model/tests/test_autonomy_send_gate.py +38 -0
  62. package/python/biocore_model/tests/test_autonomy_silence.py +37 -0
  63. package/python/biocore_model/tests/test_autonomy_status.py +35 -0
  64. package/python/biocore_model/tests/test_autonomy_test_trigger.py +24 -0
  65. package/python/biocore_model/tests/test_autonomy_tick.py +148 -0
  66. package/python/biocore_model/tests/test_autonomy_v4.py +64 -0
  67. package/python/biocore_model/tests/test_autonomy_v5.py +43 -0
  68. package/python/biocore_model/tests/test_autonomy_v7.py +48 -0
  69. package/python/biocore_model/tests/test_autonomy_v_theta.py +85 -0
  70. package/python/biocore_model/tests/test_batch1_wiring.py +231 -0
  71. package/python/biocore_model/tests/test_batch2_wiring.py +231 -0
  72. package/python/biocore_model/tests/test_batch3_wiring.py +223 -0
  73. package/python/biocore_model/tests/test_bio_tick.py +202 -0
  74. package/python/biocore_model/tests/test_boundary.py +183 -0
  75. package/python/biocore_model/tests/test_boundary_gate.py +295 -0
  76. package/python/biocore_model/tests/test_brain_split.py +99 -0
  77. package/python/biocore_model/tests/test_co_recall.py +350 -0
  78. package/python/biocore_model/tests/test_commitment_extractor.py +197 -0
  79. package/python/biocore_model/tests/test_compassion_fatigue.py +84 -0
  80. package/python/biocore_model/tests/test_connection_bids.py +69 -0
  81. package/python/biocore_model/tests/test_core_bridge.py +88 -0
  82. package/python/biocore_model/tests/test_core_bridge_episode.py +14 -0
  83. package/python/biocore_model/tests/test_core_bridge_forget.py +17 -0
  84. package/python/biocore_model/tests/test_core_bridge_identity.py +15 -0
  85. package/python/biocore_model/tests/test_core_bridge_media.py +17 -0
  86. package/python/biocore_model/tests/test_core_bridge_relation.py +17 -0
  87. package/python/biocore_model/tests/test_core_bridge_summary.py +17 -0
  88. package/python/biocore_model/tests/test_core_client.py +156 -0
  89. package/python/biocore_model/tests/test_core_commands.py +146 -0
  90. package/python/biocore_model/tests/test_core_mode.py +161 -0
  91. package/python/biocore_model/tests/test_core_state.py +240 -0
  92. package/python/biocore_model/tests/test_criticality.py +61 -0
  93. package/python/biocore_model/tests/test_cross_session_copy.py +67 -0
  94. package/python/biocore_model/tests/test_debug_values.py +169 -0
  95. package/python/biocore_model/tests/test_defense_mechanisms.py +177 -0
  96. package/python/biocore_model/tests/test_delta_budget.py +157 -0
  97. package/python/biocore_model/tests/test_diag_trace.py +201 -0
  98. package/python/biocore_model/tests/test_directives_compat.py +698 -0
  99. package/python/biocore_model/tests/test_dissonance.py +97 -0
  100. package/python/biocore_model/tests/test_drift_sign.py +87 -0
  101. package/python/biocore_model/tests/test_embedding_fallback.py +277 -0
  102. package/python/biocore_model/tests/test_embedding_local.py +279 -0
  103. package/python/biocore_model/tests/test_embedding_mode.py +175 -0
  104. package/python/biocore_model/tests/test_embedding_modes.py +342 -0
  105. package/python/biocore_model/tests/test_emotion_geometry.py +114 -0
  106. package/python/biocore_model/tests/test_emotion_probe.py +214 -0
  107. package/python/biocore_model/tests/test_emotion_probe_concurrency.py +128 -0
  108. package/python/biocore_model/tests/test_entity_graph.py +198 -0
  109. package/python/biocore_model/tests/test_entity_resolve.py +446 -0
  110. package/python/biocore_model/tests/test_envy.py +173 -0
  111. package/python/biocore_model/tests/test_exit_or_silence.py +209 -0
  112. package/python/biocore_model/tests/test_expr_guard.py +1 -0
  113. package/python/biocore_model/tests/test_expr_intent.py +66 -0
  114. package/python/biocore_model/tests/test_expr_state.py +66 -0
  115. package/python/biocore_model/tests/test_expr_template.py +71 -0
  116. package/python/biocore_model/tests/test_expr_tone.py +41 -0
  117. package/python/biocore_model/tests/test_extreme_scenarios.py +147 -0
  118. package/python/biocore_model/tests/test_feedback_flow.py +185 -0
  119. package/python/biocore_model/tests/test_feedback_guard.py +65 -0
  120. package/python/biocore_model/tests/test_feedback_llm.py +59 -0
  121. package/python/biocore_model/tests/test_feedback_llm_inferred.py +140 -0
  122. package/python/biocore_model/tests/test_feedback_memory.py +63 -0
  123. package/python/biocore_model/tests/test_feedback_user.py +61 -0
  124. package/python/biocore_model/tests/test_frustration_curve.py +66 -0
  125. package/python/biocore_model/tests/test_global_bio_state.py +116 -0
  126. package/python/biocore_model/tests/test_governance.py +181 -0
  127. package/python/biocore_model/tests/test_granularity.py +133 -0
  128. package/python/biocore_model/tests/test_graph_extract.py +224 -0
  129. package/python/biocore_model/tests/test_graph_recall.py +226 -0
  130. package/python/biocore_model/tests/test_graph_sqlite.py +298 -0
  131. package/python/biocore_model/tests/test_growth.py +301 -0
  132. package/python/biocore_model/tests/test_intent_render.py +60 -0
  133. package/python/biocore_model/tests/test_intero_regulation.py +224 -0
  134. package/python/biocore_model/tests/test_intero_style.py +146 -0
  135. package/python/biocore_model/tests/test_item3_numeric.py +105 -0
  136. package/python/biocore_model/tests/test_item6_numeric.py +116 -0
  137. package/python/biocore_model/tests/test_layered_memory.py +174 -0
  138. package/python/biocore_model/tests/test_leak_guard.py +75 -0
  139. package/python/biocore_model/tests/test_learning_probe.py +93 -0
  140. package/python/biocore_model/tests/test_life_loop.py +222 -0
  141. package/python/biocore_model/tests/test_life_loop_10s.py +131 -0
  142. package/python/biocore_model/tests/test_local_embedding_health.py +258 -0
  143. package/python/biocore_model/tests/test_memory_affect_encoding.py +79 -0
  144. package/python/biocore_model/tests/test_memory_backend.py +332 -0
  145. package/python/biocore_model/tests/test_memory_dedup.py +316 -0
  146. package/python/biocore_model/tests/test_memory_dual.py +219 -0
  147. package/python/biocore_model/tests/test_memory_dual_wiring.py +219 -0
  148. package/python/biocore_model/tests/test_memory_feedback.py +241 -0
  149. package/python/biocore_model/tests/test_memory_fixes.py +84 -0
  150. package/python/biocore_model/tests/test_memory_forgetting.py +58 -0
  151. package/python/biocore_model/tests/test_memory_inject.py +211 -0
  152. package/python/biocore_model/tests/test_memory_mood_recall.py +118 -0
  153. package/python/biocore_model/tests/test_memory_store.py +372 -0
  154. package/python/biocore_model/tests/test_metacog.py +103 -0
  155. package/python/biocore_model/tests/test_migrate.py +245 -0
  156. package/python/biocore_model/tests/test_migrate_store.py +194 -0
  157. package/python/biocore_model/tests/test_mind_wander.py +65 -0
  158. package/python/biocore_model/tests/test_mood.py +35 -0
  159. package/python/biocore_model/tests/test_narrative.py +114 -0
  160. package/python/biocore_model/tests/test_needs_esteem.py +51 -0
  161. package/python/biocore_model/tests/test_needs_events.py +205 -0
  162. package/python/biocore_model/tests/test_needs_sleep.py +54 -0
  163. package/python/biocore_model/tests/test_noise_bank.py +54 -0
  164. package/python/biocore_model/tests/test_numeric_schema.py +98 -0
  165. package/python/biocore_model/tests/test_output_protocol.py +65 -0
  166. package/python/biocore_model/tests/test_page_debug.py +147 -0
  167. package/python/biocore_model/tests/test_page_status_autonomy.py +215 -0
  168. package/python/biocore_model/tests/test_pipeline.py +345 -0
  169. package/python/biocore_model/tests/test_pipeline_api.py +192 -0
  170. package/python/biocore_model/tests/test_pipeline_mode.py +222 -0
  171. package/python/biocore_model/tests/test_plugin_config.py +85 -0
  172. package/python/biocore_model/tests/test_policy_learning.py +116 -0
  173. package/python/biocore_model/tests/test_possible_selves.py +88 -0
  174. package/python/biocore_model/tests/test_predictor.py +227 -0
  175. package/python/biocore_model/tests/test_preference_drift.py +142 -0
  176. package/python/biocore_model/tests/test_preset.py +142 -0
  177. package/python/biocore_model/tests/test_proactive_context.py +70 -0
  178. package/python/biocore_model/tests/test_proactive_history.py +90 -0
  179. package/python/biocore_model/tests/test_proactive_memory.py +141 -0
  180. package/python/biocore_model/tests/test_proactive_send_record.py +88 -0
  181. package/python/biocore_model/tests/test_probe_contract.py +71 -0
  182. package/python/biocore_model/tests/test_prospective.py +154 -0
  183. package/python/biocore_model/tests/test_provider_fallback.py +89 -0
  184. package/python/biocore_model/tests/test_reconstruction.py +111 -0
  185. package/python/biocore_model/tests/test_regulation_strategies.py +228 -0
  186. package/python/biocore_model/tests/test_relation_stage.py +114 -0
  187. package/python/biocore_model/tests/test_relationship_stage.py +59 -0
  188. package/python/biocore_model/tests/test_release_140.py +55 -0
  189. package/python/biocore_model/tests/test_repair_after_boundary.py +249 -0
  190. package/python/biocore_model/tests/test_reply_limit.py +86 -0
  191. package/python/biocore_model/tests/test_reply_limit_persist.py +48 -0
  192. package/python/biocore_model/tests/test_reply_split.py +52 -0
  193. package/python/biocore_model/tests/test_reply_split_astrbot.py +66 -0
  194. package/python/biocore_model/tests/test_reply_split_inject.py +53 -0
  195. package/python/biocore_model/tests/test_rerank.py +229 -0
  196. package/python/biocore_model/tests/test_rollback.py +175 -0
  197. package/python/biocore_model/tests/test_runtime.py +175 -0
  198. package/python/biocore_model/tests/test_runtime_active.py +205 -0
  199. package/python/biocore_model/tests/test_runtime_pipeline.py +1583 -0
  200. package/python/biocore_model/tests/test_runtime_proactive.py +231 -0
  201. package/python/biocore_model/tests/test_scheduler.py +79 -0
  202. package/python/biocore_model/tests/test_secrecy.py +105 -0
  203. package/python/biocore_model/tests/test_self_esteem.py +45 -0
  204. package/python/biocore_model/tests/test_self_intent.py +347 -0
  205. package/python/biocore_model/tests/test_self_interest.py +183 -0
  206. package/python/biocore_model/tests/test_self_layer_wiring.py +198 -0
  207. package/python/biocore_model/tests/test_self_verification.py +113 -0
  208. package/python/biocore_model/tests/test_situation.py +142 -0
  209. package/python/biocore_model/tests/test_sleep_circadian.py +41 -0
  210. package/python/biocore_model/tests/test_social_pain.py +49 -0
  211. package/python/biocore_model/tests/test_social_repair.py +67 -0
  212. package/python/biocore_model/tests/test_social_theory.py +78 -0
  213. package/python/biocore_model/tests/test_source_monitoring.py +204 -0
  214. package/python/biocore_model/tests/test_speak_like_group.py +36 -0
  215. package/python/biocore_model/tests/test_stages.py +226 -0
  216. package/python/biocore_model/tests/test_state_tag.py +86 -0
  217. package/python/biocore_model/tests/test_template_expr.py +65 -0
  218. package/python/biocore_model/tests/test_text_inference.py +70 -0
  219. package/python/biocore_model/tests/test_theta_params.py +115 -0
  220. package/python/biocore_model/tests/test_topic_control.py +200 -0
  221. package/python/biocore_model/tests/test_turn_memory.py +105 -0
  222. package/python/biocore_model/tests/test_turn_memory_panel.py +174 -0
  223. package/python/biocore_model/tests/test_ui_settings.py +84 -0
  224. package/python/biocore_model/tests/test_ui_settings_authority.py +179 -0
  225. package/python/biocore_model/tests/test_user_appraisal_async.py +155 -0
  226. package/python/biocore_model/tests/test_user_classify.py +122 -0
  227. package/python/biocore_model/tests/test_value_order.py +117 -0
  228. package/python/biocore_model/tests/test_voice_bank.py +105 -0
  229. package/python/biocore_model/tests/test_waiting_sense.py +216 -0
  230. package/python/biocore_model/tests/test_x_directives.py +101 -0
  231. package/python/biocore_model/tests/ui_compat_check.mjs +22 -0
  232. package/python/biocore_model/tests/view_mode_check.mjs +17 -0
  233. package/python/biocore_runtime/autonomy.py +208 -0
  234. package/python/biocore_runtime/core_state.py +302 -0
  235. package/python/biocore_runtime/governance.py +495 -0
  236. package/python/biocore_runtime/growth.py +528 -0
  237. package/python/biocore_runtime/pipeline.py +850 -23
  238. package/python/biocore_runtime/relationship_state.py +214 -0
  239. package/python/biocore_runtime/self_layer.py +103 -0
  240. package/python/biocore_runtime/service.py +162 -0
  241. package/python/biocore_runtime/standby.py +472 -0
  242. package/ui/panel-layout.js +83 -39
  243. package/ui/panel.css +885 -743
  244. package/ui/panel.html +356 -296
  245. package/ui/panel.js +739 -865
@@ -0,0 +1,245 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Real old-layout migration test: 1.3.4 data -> 1.4.0, then idempotency."""
3
+ import ast
4
+ import json
5
+ import os
6
+ import tempfile
7
+ import time
8
+ import types
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ from biocore_model import migrate
13
+
14
+
15
+ class FakePlugin:
16
+ def __init__(self, data_dir):
17
+ self.data_dir = str(data_dir)
18
+
19
+ def _autonomy_params(self):
20
+ return {"theta": 0.5239, "cooldown_s": 1625, "daily_cap": 3,
21
+ "tau_hours": 16.0}
22
+
23
+
24
+ def _old_layout(root):
25
+ data = Path(root) / "plugins" / "astrbot_plugin_biocore" / "data"
26
+ (data / "memory").mkdir(parents=True, exist_ok=True)
27
+ (data / "states").mkdir(parents=True, exist_ok=True)
28
+ (data / "ui_settings.json").write_text(json.dumps({
29
+ "autonomy": {"enabled": True, "threshold": 0.7,
30
+ "accumulator": {"V": -0.16, "drift": 0.02,
31
+ "last_tick_ts": 123.0}},
32
+ "proactive_autonomy_threshold": 0.7,
33
+ "proactive_autonomy_max_chars": 120,
34
+ "schema": 1,
35
+ }, ensure_ascii=False), encoding="utf-8")
36
+ mem = data / "memory" / "s1.json"
37
+ mem.write_text(json.dumps({"entries": [
38
+ {"id": "m1", "text": "老记忆一"},
39
+ {"id": "m2", "text": "老记忆二"},
40
+ {"id": "m3", "text": "老记忆三"},
41
+ ]}, ensure_ascii=False), encoding="utf-8")
42
+ old = time.time() - 10 * 86400
43
+ os.utime(mem, (old, old))
44
+ (data / "states" / "u1.json").write_text(
45
+ json.dumps({"umo": "user-x"}), encoding="utf-8")
46
+ return data
47
+
48
+
49
+ def _load_methods(names, namespace=None):
50
+ source = (Path(__file__).resolve().parents[2] / "main.py").read_text(
51
+ encoding="utf-8")
52
+ tree = ast.parse(source)
53
+ cls = next(n for n in tree.body
54
+ if isinstance(n, ast.ClassDef) and n.name == "BioCorePlugin")
55
+ ns = dict(namespace or {})
56
+ fns = {}
57
+ for node in cls.body:
58
+ if isinstance(node, ast.FunctionDef) and node.name in names:
59
+ clone = ast.FunctionDef(name=node.name, args=node.args, body=node.body,
60
+ decorator_list=[], returns=node.returns,
61
+ type_comment=None, lineno=node.lineno,
62
+ col_offset=node.col_offset,
63
+ end_lineno=node.end_lineno,
64
+ end_col_offset=node.end_col_offset)
65
+ exec(compile(ast.Module(body=[clone], type_ignores=[]), "<mig>", "exec"), ns)
66
+ fns[node.name] = ns[node.name]
67
+ return fns
68
+
69
+ class _Recorder:
70
+ """Minimal logger stub; keeps tests free of the stdlib logging import."""
71
+
72
+ def __init__(self):
73
+ self.lines = []
74
+
75
+ def _log(self, msg, *args, **kwargs):
76
+ try:
77
+ self.lines.append(str(msg) % args if args else str(msg))
78
+ except Exception:
79
+ self.lines.append(str(msg))
80
+
81
+ debug = info = warning = error = exception = critical = _log
82
+
83
+
84
+ class MigrationTest(unittest.TestCase):
85
+ def test_old_data_migrates_and_backfills(self):
86
+ with tempfile.TemporaryDirectory() as tmp:
87
+ data = _old_layout(tmp)
88
+ plugin = FakePlugin(data)
89
+ rec = _Recorder()
90
+ saved_logger = migrate.logger
91
+ migrate.logger = rec
92
+ try:
93
+ report = migrate.migrate(plugin)
94
+ finally:
95
+ migrate.logger = saved_logger
96
+ self.assertEqual(report["applied"],
97
+ ["settings", "accumulator", "memory"])
98
+ auth = (Path(tmp) / "plugin_data" / "astrbot_plugin_biocore"
99
+ / "ui_settings.json")
100
+ saved = json.loads(auth.read_text(encoding="utf-8"))
101
+ self.assertEqual(saved.get("schema_version"),
102
+ migrate.SCHEMA_VERSION)
103
+ acc = saved["autonomy"]["accumulator"]
104
+ self.assertAlmostEqual(float(acc["theta"]), 0.5239, places=3)
105
+ self.assertGreaterEqual(float(acc["V"]), 0.0)
106
+ self.assertEqual(acc["cooldown_seconds"], 1625)
107
+ self.assertEqual(acc["daily_cap"], 3)
108
+ self.assertEqual(acc["last_umo"], "user-x")
109
+ mem = json.loads((data / "memory" / "s1.json").read_text(
110
+ encoding="utf-8"))
111
+ self.assertEqual(len(mem["entries"]), 3)
112
+ for entry in mem["entries"]:
113
+ self.assertIn("storage_s", entry)
114
+ self.assertIn("retrieval_s", entry)
115
+ self.assertIn("access_count", entry)
116
+ self.assertIn("arousal_at_encoding", entry)
117
+ self.assertFalse(entry["hidden"])
118
+ self.assertGreater(entry["storage_s"], 0.0)
119
+ # Old 10-day file -> power-law retrieval below a fresh file.
120
+ self.assertLess(mem["entries"][0]["retrieval_s"], 0.5)
121
+ joined = "\n".join(rec.lines)
122
+ for token in ("migrate done:", "settings=", "accumulator=",
123
+ "memory=", "warnings="):
124
+ self.assertIn(token, joined)
125
+
126
+ def test_schema_version_survives_ui_settings_save(self):
127
+ with tempfile.TemporaryDirectory() as tmp:
128
+ data = _old_layout(tmp)
129
+ plugin = FakePlugin(data)
130
+ migrate.migrate(plugin)
131
+ from biocore_model import ui_settings
132
+ ui_settings.save(plugin, {"autonomy": {"send": True}})
133
+ auth = (Path(tmp) / "plugin_data" / "astrbot_plugin_biocore"
134
+ / "ui_settings.json")
135
+ saved = json.loads(auth.read_text(encoding="utf-8"))
136
+ self.assertEqual(saved.get("schema_version"),
137
+ migrate.SCHEMA_VERSION)
138
+ self.assertEqual(migrate.migrate(plugin)["applied"], [])
139
+
140
+ def test_second_run_is_idempotent(self):
141
+ with tempfile.TemporaryDirectory() as tmp:
142
+ data = _old_layout(tmp)
143
+ plugin = FakePlugin(data)
144
+ migrate.migrate(plugin)
145
+ auth = (Path(tmp) / "plugin_data" / "astrbot_plugin_biocore"
146
+ / "ui_settings.json")
147
+ mem = data / "memory" / "s1.json"
148
+ before_settings = auth.read_bytes()
149
+ before_mem = mem.read_bytes()
150
+ report = migrate.migrate(plugin)
151
+ self.assertEqual(report["applied"], [])
152
+ self.assertEqual(auth.read_bytes(), before_settings)
153
+ self.assertEqual(mem.read_bytes(), before_mem)
154
+
155
+ def test_fresh_install_is_noop_and_safe(self):
156
+ with tempfile.TemporaryDirectory() as tmp:
157
+ fresh = Path(tmp) / "plugin_data" / "astrbot_plugin_biocore"
158
+ fresh.mkdir(parents=True)
159
+ plugin = FakePlugin(fresh)
160
+ report = migrate.migrate(plugin)
161
+ self.assertEqual(report["applied"], [])
162
+ self.assertEqual(report["warnings"], [])
163
+ auth = fresh / "ui_settings.json"
164
+ self.assertTrue(auth.is_file())
165
+ self.assertEqual(json.loads(auth.read_text(encoding="utf-8"))
166
+ .get("schema_version"),
167
+ migrate.SCHEMA_VERSION)
168
+
169
+
170
+ if __name__ == "__main__":
171
+ unittest.main()
172
+
173
+ class HardeningTest(unittest.TestCase):
174
+ def test_atomic_write_failure_leaves_no_tmp_or_partial(self):
175
+ with tempfile.TemporaryDirectory() as tmp:
176
+ target = Path(tmp) / "x.json"
177
+ target.write_text('{"old": 1}', encoding="utf-8")
178
+ from unittest import mock
179
+ with mock.patch.object(migrate.os, "replace", side_effect=OSError("boom")):
180
+ with self.assertRaises(OSError):
181
+ migrate._write(target, {"new": 2})
182
+ self.assertEqual(target.read_text(encoding="utf-8"), '{"old": 1}')
183
+ self.assertFalse((Path(tmp) / "x.json.tmp").exists())
184
+
185
+ def test_backup_contains_three_classes_and_migrates(self):
186
+ with tempfile.TemporaryDirectory() as tmp:
187
+ data = _old_layout(tmp)
188
+ plugin = FakePlugin(data)
189
+ report = migrate.migrate(plugin)
190
+ backup = Path(report["backup_dir"])
191
+ self.assertTrue(backup.is_dir())
192
+ self.assertTrue((backup / "settings" / "ui_settings.json").is_file())
193
+ self.assertEqual(len(list((backup / "memory").glob("*.json"))), 1)
194
+ self.assertEqual(len(list((backup / "states").glob("*.json"))), 1)
195
+ self.assertIn("memory", report["applied"])
196
+
197
+ def test_failed_migration_keeps_old_files(self):
198
+ with tempfile.TemporaryDirectory() as tmp:
199
+ data = _old_layout(tmp)
200
+ old_legacy = (data / "ui_settings.json").read_bytes()
201
+ plugin = FakePlugin(data)
202
+ from unittest import mock
203
+ with mock.patch.object(migrate, "_write", side_effect=OSError("disk")):
204
+ report = migrate.migrate(plugin)
205
+ self.assertTrue(report["warnings"])
206
+ self.assertEqual((data / "ui_settings.json").read_bytes(), old_legacy)
207
+ auth = Path(tmp) / "plugin_data" / "astrbot_plugin_biocore" / "ui_settings.json"
208
+ self.assertFalse(auth.exists())
209
+
210
+ def test_restore_brings_backup_back(self):
211
+ with tempfile.TemporaryDirectory() as tmp:
212
+ data = _old_layout(tmp)
213
+ plugin = FakePlugin(data)
214
+ report = migrate.migrate(plugin)
215
+ backup = report["backup_dir"]
216
+ auth = (Path(tmp) / "plugin_data" / "astrbot_plugin_biocore"
217
+ / "ui_settings.json")
218
+ auth.write_text("{}", encoding="utf-8")
219
+ mem = data / "memory" / "s1.json"
220
+ mem.write_text("{}", encoding="utf-8")
221
+ result = migrate.restore(backup, plugin)
222
+ self.assertTrue(result["ok"])
223
+ restored = json.loads(auth.read_text(encoding="utf-8"))
224
+ self.assertIn("autonomy", restored)
225
+ self.assertIn("entries", json.loads(mem.read_text(encoding="utf-8")))
226
+ self.assertGreaterEqual(result["restored"]["states"], 1)
227
+
228
+ def test_retry_path_runs_once(self):
229
+ with tempfile.TemporaryDirectory() as tmp:
230
+ data = _old_layout(tmp)
231
+ plugin = FakePlugin(data)
232
+ ns = {"time": time, "logger": types.SimpleNamespace(
233
+ warning=lambda *a, **k: None, info=lambda *a, **k: None)}
234
+ fns = _load_methods(("_migration_payload", "_ensure_migration_retry_once"),
235
+ namespace=ns)
236
+ for name, fn in fns.items():
237
+ setattr(plugin, name, types.MethodType(fn, plugin))
238
+ plugin._migration_retry_done = False
239
+ plugin._migration_report = {}
240
+ payload = plugin._ensure_migration_retry_once()
241
+ self.assertGreaterEqual(payload["schema_version"], 2)
242
+ first = (data / "ui_settings.json").read_bytes()
243
+ plugin._ensure_migration_retry_once()
244
+ self.assertEqual((data / "ui_settings.json").read_bytes(), first)
245
+
@@ -0,0 +1,194 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Offline tests for biocore_model.migrate_store (temp dirs only)."""
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+ from unittest import mock
9
+
10
+ from biocore_model import migrate_store
11
+
12
+
13
+ def entry(**overrides):
14
+ value = {"id": "abc123", "text": "hello memory", "ts": 1700000000.0, "role": "user",
15
+ "importance": 0.8, "pin": True, "scope": "default", "tags": ["plan"]}
16
+ value.update(overrides)
17
+ return value
18
+
19
+
20
+ class ConvertTest(unittest.TestCase):
21
+ def test_normal_mapping(self):
22
+ core, report = migrate_store.convert([entry()])
23
+ self.assertEqual(report["converted"], 1)
24
+ self.assertEqual(core[0]["id"], "abc123")
25
+ self.assertEqual(core[0]["text"], "hello memory")
26
+ self.assertEqual(core[0]["ts"], 1700000000.0)
27
+ self.assertEqual(core[0]["role"], "user")
28
+ self.assertEqual(core[0]["importance"], 0.8)
29
+ self.assertTrue(core[0]["pin"])
30
+ self.assertEqual(core[0]["tags"], ["plan"])
31
+ self.assertEqual(core[0]["client_scope"], "global")
32
+
33
+ def test_defaults_for_missing_optional_fields(self):
34
+ core, _ = migrate_store.convert([{"id": "x", "text": "only text"}])
35
+ self.assertEqual(core[0]["role"], "user")
36
+ self.assertEqual(core[0]["tags"], [])
37
+ self.assertEqual(core[0]["importance"], 0.5)
38
+ self.assertFalse(core[0]["pin"])
39
+ self.assertEqual(core[0]["client_scope"], "global")
40
+
41
+ def test_unknown_fields_are_reported_not_dropped_silently(self):
42
+ core, report = migrate_store.convert([entry(vector=[0.1, 0.2], sender_id="42", memory_tier="hot")])
43
+ self.assertEqual(report.get("unmapped_fields", {}).get("vector", 0), 0)
44
+ self.assertEqual(report["vectors_migrated"], 1)
45
+ self.assertEqual(report["unmapped_fields"]["sender_id"], 1)
46
+ self.assertEqual(report["unmapped_fields"]["memory_tier"], 1)
47
+ self.assertIn("vector", core[0])
48
+
49
+ def test_missing_text_is_skipped_and_counted(self):
50
+ core, report = migrate_store.convert([{"id": "a", "text": ""}, {"id": "b", "text": "ok"}])
51
+ self.assertEqual(report["converted"], 1)
52
+ self.assertEqual(report["skipped"], 1)
53
+ self.assertEqual(report["missing_required"]["text"], 1)
54
+ self.assertEqual(core[0]["id"], "b")
55
+
56
+ def test_empty_and_non_dict_entries(self):
57
+ core, report = migrate_store.convert([])
58
+ self.assertEqual((report["converted"], report["skipped"]), (0, 0))
59
+ core, report = migrate_store.convert([None, "nope", entry()])
60
+ self.assertEqual(report["converted"], 1)
61
+ self.assertEqual(report["skipped"], 2)
62
+
63
+ def test_generated_id_is_deterministic(self):
64
+ row = {"text": "no id here", "ts": 1700000001.0, "role": "user"}
65
+ first, report = migrate_store.convert([row])
66
+ second, _ = migrate_store.convert([row])
67
+ self.assertEqual(first[0]["id"], second[0]["id"])
68
+ self.assertEqual(len(first[0]["id"]), 12)
69
+ self.assertEqual(report["generated_ids"], 1)
70
+
71
+ def test_scope_mapping(self):
72
+ core, _ = migrate_store.convert([entry(scope="session:dsh-1"), dict(entry(), id="b", scope="default")])
73
+ self.assertEqual(core[0]["client_scope"], "session:dsh-1")
74
+ self.assertEqual(core[1]["client_scope"], "global")
75
+
76
+ def test_valid_at_is_carried(self):
77
+ core, _ = migrate_store.convert([entry(valid_at=1700000000.0)])
78
+ self.assertEqual(core[0]["valid_at"], 1700000000.0)
79
+
80
+ def test_convert_is_idempotent(self):
81
+ rows = [entry(), entry(id="b", text="second")]
82
+ first, report_first = migrate_store.convert(rows)
83
+ second, report_second = migrate_store.convert(rows)
84
+ self.assertEqual(first, second)
85
+ self.assertEqual(report_first, report_second)
86
+
87
+
88
+ class SourceAndRunTest(unittest.TestCase):
89
+ def setUp(self):
90
+ self.tmp = tempfile.TemporaryDirectory()
91
+ self.root = Path(self.tmp.name)
92
+ self.store_file = self.root / "memory.json"
93
+ self.store_file.write_text(json.dumps({"entries": [entry()]}), encoding="utf-8")
94
+
95
+ def tearDown(self):
96
+ self.tmp.cleanup()
97
+
98
+ def test_detect_source_via_env(self):
99
+ with mock.patch.dict(os.environ, {migrate_store.SOURCE_ENV: str(self.store_file)}):
100
+ self.assertEqual(migrate_store.detect_source(), str(self.store_file))
101
+
102
+ def test_detect_source_from_launcher_layout(self):
103
+ plugin = self.root / "instances" / "inst1" / "core" / "data" / "plugin_data" / "astrbot_plugin_biocore_standalone"
104
+ memory = plugin / "memory"
105
+ memory.mkdir(parents=True)
106
+ (memory / "a.json").write_text(json.dumps({"entries": [entry()]}), encoding="utf-8")
107
+ with mock.patch.dict(os.environ, {migrate_store.SOURCE_ENV: ""}):
108
+ found = migrate_store.detect_source(candidates=[self.root / "instances"])
109
+ self.assertEqual(found, str(memory))
110
+
111
+ def test_detect_source_missing_returns_none(self):
112
+ empty = self.root / "nothing"
113
+ empty.mkdir()
114
+ with mock.patch.dict(os.environ, {migrate_store.SOURCE_ENV: ""}):
115
+ self.assertIsNone(migrate_store.detect_source(candidates=[empty]))
116
+
117
+ def test_corrupt_source_reports_and_skips(self):
118
+ corrupt = self.root / "bad.json"
119
+ corrupt.write_text("{ not json", encoding="utf-8")
120
+ result = migrate_store.run(src=str(corrupt))
121
+ self.assertEqual(result["corrupt_files"], 1)
122
+ self.assertEqual(result["error"], "corrupt_source")
123
+ self.assertFalse(result["applied"])
124
+
125
+ def test_dry_run_does_not_write(self):
126
+ dst = self.root / "out" / "memory.json"
127
+ result = migrate_store.run(src=str(self.store_file), dst=str(dst), apply=False)
128
+ self.assertTrue(result["ok"])
129
+ self.assertTrue(result["dry_run"])
130
+ self.assertFalse(result["applied"])
131
+ self.assertFalse(dst.exists())
132
+ self.assertEqual(result["converted"], 1)
133
+
134
+ def test_apply_writes_atomically(self):
135
+ dst = self.root / "out" / "memory.json"
136
+ result = migrate_store.run(src=str(self.store_file), dst=str(dst), apply=True)
137
+ self.assertTrue(result["applied"])
138
+ doc = json.loads(dst.read_text(encoding="utf-8"))
139
+ self.assertEqual(len(doc["entries"]), 1)
140
+ self.assertEqual(doc["schema_version"], 1)
141
+ self.assertEqual(list(dst.parent.glob("*.tmp")), [])
142
+
143
+ def test_target_non_empty_requires_force(self):
144
+ dst = self.root / "out" / "memory.json"
145
+ dst.parent.mkdir(parents=True)
146
+ dst.write_text(json.dumps({"entries": [{"id": "keep"}]}), encoding="utf-8")
147
+ blocked = migrate_store.run(src=str(self.store_file), dst=str(dst), apply=True)
148
+ self.assertEqual(blocked["error"], "target_not_empty")
149
+ self.assertFalse(blocked["applied"])
150
+ self.assertIn("keep", dst.read_text(encoding="utf-8"))
151
+ forced = migrate_store.run(src=str(self.store_file), dst=str(dst), apply=True, force=True)
152
+ self.assertTrue(forced["applied"])
153
+ self.assertEqual(len(json.loads(dst.read_text(encoding="utf-8"))["entries"]), 1)
154
+
155
+ def test_source_not_found_reports_error(self):
156
+ with mock.patch.dict(os.environ, {migrate_store.SOURCE_ENV: ""}):
157
+ result = migrate_store.run(src=str(self.root / "missing" / "memory.json"))
158
+ self.assertEqual(result["error"], "source_not_found")
159
+ self.assertFalse(result["ok"])
160
+
161
+ def test_apply_without_target_errors(self):
162
+ result = migrate_store.run(src=str(self.store_file), apply=True)
163
+ self.assertEqual(result["error"], "target_required")
164
+
165
+ def test_sample_contains_three_entries_max(self):
166
+ rows = [entry(id=str(index), text="memory " + str(index)) for index in range(5)]
167
+ src = self.root / "many.json"
168
+ src.write_text(json.dumps({"entries": rows}), encoding="utf-8")
169
+ result = migrate_store.run(src=str(src))
170
+ self.assertEqual(result["converted"], 5)
171
+ self.assertEqual(len(result["sample"]), 3)
172
+
173
+
174
+ class VectorMigrationTest(unittest.TestCase):
175
+ def test_vector_is_kept_with_model(self):
176
+ entries = [{"id": "v1", "text": "hello", "vector": [0.1, 0.2, 0.3], "embedding_model": "m1"}]
177
+ core, report = migrate_store.convert(entries)
178
+ self.assertEqual(core[0]["vector"], [0.1, 0.2, 0.3])
179
+ self.assertEqual(core[0]["vector_model"], "m1")
180
+ self.assertEqual(report["vectors_migrated"], 1)
181
+ self.assertEqual(report["vectors_dropped"], 0)
182
+
183
+ def test_invalid_vector_is_dropped_and_counted(self):
184
+ entries = [{"id": "v2", "text": "hello", "vector": [0.1, "x", 0.3]}]
185
+ core, report = migrate_store.convert(entries)
186
+ self.assertNotIn("vector", core[0])
187
+ self.assertEqual(report["vectors_dropped"], 1)
188
+ self.assertEqual(report["vectors_migrated"], 0)
189
+
190
+ def test_empty_vector_is_dropped(self):
191
+ entries = [{"id": "v3", "text": "hello", "vector": []}]
192
+ core, report = migrate_store.convert(entries)
193
+ self.assertNotIn("vector", core[0])
194
+ self.assertEqual(report["vectors_dropped"], 0)
@@ -0,0 +1,65 @@
1
+ # -*- coding: utf-8 -*-
2
+ """HUMAN-4 mind wandering tests: weights, deterministic sampling, rumination bias."""
3
+ import unittest
4
+
5
+ from biocore_model import mind_wander as mw
6
+
7
+ LOOPS = [{"topic": "calm", "tension": 0.0}, {"topic": "tense", "tension": 1.0}]
8
+
9
+
10
+ class WeightTest(unittest.TestCase):
11
+ def test_weights_positive(self):
12
+ for weight in mw.weights(LOOPS, rumination=0.5):
13
+ self.assertGreater(weight, 0.0)
14
+
15
+ def test_rumination_boosts_tension(self):
16
+ calm, tense = mw.weights(LOOPS, rumination=1.0)
17
+ self.assertGreater(tense, calm)
18
+
19
+ def test_no_rumination_equal_tension(self):
20
+ loops = [{"topic": "a", "tension": 0.5}, {"topic": "b", "tension": 0.5}]
21
+ a, b = mw.weights(loops, rumination=0.0)
22
+ self.assertEqual(a, b)
23
+
24
+
25
+ class ChooseTest(unittest.TestCase):
26
+ def test_deterministic(self):
27
+ self.assertEqual(mw.choose(LOOPS, 0.5, seed=4, now=9),
28
+ mw.choose(LOOPS, 0.5, seed=4, now=9))
29
+
30
+ def test_fields(self):
31
+ row = mw.choose(LOOPS, 0.0, seed=1, now=1)
32
+ self.assertIn(row["topic"], ("calm", "tense"))
33
+ self.assertIn("weight", row)
34
+ self.assertIn("total", row)
35
+
36
+ def test_empty_returns_none(self):
37
+ self.assertIsNone(mw.choose([], 0.5, seed=1, now=1))
38
+
39
+ def test_single_item_always(self):
40
+ row = mw.choose([{"topic": "only", "tension": 0.1}], 0.9, seed=7, now=7)
41
+ self.assertEqual(row["topic"], "only")
42
+
43
+ def test_rumination_biases_tense(self):
44
+ picks = [mw.choose(LOOPS, 1.0, seed=index, now=index)["topic"]
45
+ for index in range(30)]
46
+ self.assertGreaterEqual(picks.count("tense"), 18)
47
+
48
+
49
+ class WanderTest(unittest.TestCase):
50
+ def test_limit_and_distinct(self):
51
+ rows = mw.wander(LOOPS, 0.2, seed=3, now=3, limit=2)
52
+ self.assertEqual(len(rows), 2)
53
+ self.assertNotEqual(rows[0]["topic"], rows[1]["topic"])
54
+
55
+ def test_does_not_mutate(self):
56
+ snapshot = [dict(row) for row in LOOPS]
57
+ mw.wander(LOOPS, 0.5, seed=2, now=2)
58
+ self.assertEqual(LOOPS, snapshot)
59
+
60
+ def test_seeded_helper(self):
61
+ self.assertIn(mw.seeded(5, now=5)["topic"], ("work", "friend", "rest"))
62
+
63
+
64
+ if __name__ == "__main__":
65
+ unittest.main()
@@ -0,0 +1,35 @@
1
+ # -*- coding: utf-8 -*-
2
+ import unittest
3
+ from biocore_model import mood as mood_bio
4
+
5
+ class MoodTest(unittest.TestCase):
6
+ def test_update_toward_target(self):
7
+ m = mood_bio.mood_update({'valence': 0.0, 'arousal': 0.0}, 1.0, 0.0)
8
+ self.assertGreater(m['valence'], 0); self.assertLess(m['valence'], 1)
9
+ def test_rate(self):
10
+ m = mood_bio.mood_update({'valence': 0.0, 'arousal': 0.0}, 1.0, 0.0, rate=0.05)
11
+ self.assertAlmostEqual(m['valence'], 0.05)
12
+ def test_idempotent_same_now(self):
13
+ m = mood_bio.mood_update({'valence': 0.0, 'arousal': 0.0}, 1.0, 0.0, now=100)
14
+ again = mood_bio.mood_update(m, 1.0, 0.0, now=100)
15
+ self.assertEqual(again['valence'], m['valence'])
16
+ def test_label_quadrants(self):
17
+ self.assertEqual(mood_bio.label({'valence': -0.5, 'arousal': 0.8}), '低落')
18
+ self.assertEqual(mood_bio.label({'valence': 0.5, 'arousal': 0.8}), '愉快')
19
+ self.assertEqual(mood_bio.label({'valence': 0.2, 'arousal': 0.2}), '平静偏正')
20
+ self.assertEqual(mood_bio.label({'valence': -0.2, 'arousal': 0.2}), '平静')
21
+ def test_view_fields(self):
22
+ v = mood_bio.mood_view({'valence': 0.1, 'arousal': 0.2})
23
+ for key in ('valence', 'arousal', 'label'): self.assertIn(key, v)
24
+ def test_half_life_ticks(self):
25
+ ticks = mood_bio.ticks_to_target(0.0, 1.0)
26
+ self.assertGreater(ticks, 40); self.assertLess(ticks, 100)
27
+ def test_clamp(self):
28
+ self.assertLessEqual(mood_bio.mood_update({'valence': 1.0}, 5.0, 0.0)['valence'], 1.0)
29
+ self.assertGreaterEqual(mood_bio.mood_update({'valence': -1.0}, -5.0, 0.0)['valence'], -1.0)
30
+ def test_zero_dt_no_change_without_now(self):
31
+ m = {'valence': 0.3, 'arousal': 0.2}
32
+ self.assertEqual(mood_bio.mood_update(m, 0.3, 0.2)['valence'], 0.3)
33
+
34
+ if __name__ == '__main__':
35
+ unittest.main()
@@ -0,0 +1,114 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Narrative: chapters plus the three coherence metrics."""
3
+ import unittest
4
+
5
+ from biocore_model import narrative as nv
6
+
7
+ DAY = 86400.0
8
+ BASE = 1790000000.0
9
+
10
+
11
+ def ev(kind, text, valence, day=0.0, importance=0.6):
12
+ return {"kind": kind, "text": text, "valence": valence,
13
+ "ts": BASE + day * DAY, "importance": importance}
14
+
15
+
16
+ class EntriesTest(unittest.TestCase):
17
+ def test_empty(self):
18
+ self.assertEqual(nv.entries([]), [])
19
+
20
+ def test_theme_mapping(self):
21
+ rows = nv.entries([ev("rupture", "吵了一架", -0.6)])
22
+ self.assertEqual(rows[0]["theme"], "关系裂缝")
23
+
24
+ def test_unknown_kind_falls_back(self):
25
+ rows = nv.entries([ev("weird", "小事", 0.1)])
26
+ self.assertEqual(rows[0]["theme"], nv.DEFAULT_THEME)
27
+
28
+ def test_limit_and_newest_first(self):
29
+ events = [ev("gift", "第%d次" % i, 0.4, day=i) for i in range(10)]
30
+ rows = nv.entries(events, limit=3)
31
+ self.assertEqual(len(rows), 3)
32
+ self.assertEqual(rows[0]["ts"], BASE + 9 * DAY)
33
+
34
+ def test_dedupes_same_text(self):
35
+ events = [ev("gift", "同一句话", 0.4, day=0),
36
+ ev("gift", "同一句话", 0.4, day=1)]
37
+ self.assertEqual(len(nv.entries(events)), 1)
38
+
39
+ def test_malformed_skipped(self):
40
+ rows = nv.entries([None, "x", {"text": ""}, ev("gift", "好", 0.3)])
41
+ self.assertEqual(len(rows), 1)
42
+
43
+
44
+ class ConsistencyTest(unittest.TestCase):
45
+ def test_empty_is_zero(self):
46
+ self.assertEqual(nv.consistency([]), 0.0)
47
+
48
+ def test_all_positive(self):
49
+ events = [ev("gift", "a", 0.5), ev("gift", "b", 0.3)]
50
+ self.assertEqual(nv.consistency(events), 1.0)
51
+
52
+ def test_mixed_lowers(self):
53
+ events = [ev("gift", "a", 0.5), ev("gift", "b", -0.5)]
54
+ self.assertLess(nv.consistency(events), 1.0)
55
+
56
+
57
+ class ContradictionTest(unittest.TestCase):
58
+ def test_none_when_same_sign(self):
59
+ events = [ev("rupture", "a", -0.5), ev("rupture", "b", -0.2)]
60
+ self.assertEqual(nv.contradiction(events), 0.0)
61
+
62
+ def test_detects_opposites(self):
63
+ events = [ev("rupture", "a", -0.5), ev("rupture", "b", 0.4)]
64
+ self.assertEqual(nv.contradiction(events), 1.0)
65
+
66
+
67
+ class RepairTest(unittest.TestCase):
68
+ def test_none_without_negative(self):
69
+ self.assertEqual(nv.repair([ev("gift", "a", 0.5)]), 0.0)
70
+
71
+ def test_repair_within_window(self):
72
+ events = [ev("rupture", "吵", -0.6, day=0),
73
+ ev("repair", "和好", 0.5, day=2)]
74
+ self.assertEqual(nv.repair(events), 1.0)
75
+
76
+ def test_no_repair_outside_window(self):
77
+ events = [ev("rupture", "吵", -0.6, day=0),
78
+ ev("repair", "和好", 0.5, day=30)]
79
+ self.assertEqual(nv.repair(events), 0.0)
80
+
81
+ def test_repair_needs_same_theme(self):
82
+ events = [ev("rupture", "吵", -0.6, day=0),
83
+ ev("gift", "收到礼物", 0.5, day=1)]
84
+ self.assertEqual(nv.repair(events), 0.0)
85
+
86
+
87
+ class SummaryTest(unittest.TestCase):
88
+ def test_summary_shape(self):
89
+ events = [ev("rupture", "吵", -0.6, day=0), ev("repair", "和好", 0.5, day=1)]
90
+ out = nv.summarize(events)
91
+ self.assertEqual(out["events"], 2)
92
+ self.assertIn("consistency", out["metrics"])
93
+ self.assertIn("contradiction", out["metrics"])
94
+ self.assertIn("repair", out["metrics"])
95
+ self.assertTrue(out["entries"])
96
+
97
+ def test_disabled(self):
98
+ out = nv.summarize([ev("gift", "a", 0.5)], flags={"enabled": False})
99
+ self.assertEqual(out["entries"], [])
100
+ self.assertEqual(out["metrics"]["consistency"], 0.0)
101
+
102
+
103
+ class DirectiveTest(unittest.TestCase):
104
+ def test_contradiction_invites_clarify(self):
105
+ out = nv.directives({"enabled": True, "metrics": {"contradiction": 0.9}})
106
+ self.assertTrue(out["allow_clarify"])
107
+
108
+ def test_clean_story_no_prompt(self):
109
+ out = nv.directives({"enabled": True, "metrics": {"contradiction": 0.0}})
110
+ self.assertFalse(out["allow_clarify"])
111
+
112
+
113
+ if __name__ == "__main__":
114
+ unittest.main()