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.
- package/README.md +11 -0
- package/lib/core-worker.js +268 -0
- package/lib/index.js +160 -70
- package/lib/modules/autonomy.js +125 -0
- package/lib/modules/cache-stats.js +48 -0
- package/lib/modules/core-turn.js +24 -6
- package/lib/modules/expression-inject.js +0 -10
- package/lib/modules/expression-refresh.js +0 -9
- package/lib/modules/memory-capture.js +2 -1
- package/lib/modules/memory-inject.js +50 -5
- package/lib/modules/memory-tools.js +7 -4
- package/lib/modules/python-env.js +46 -0
- package/lib/modules/safety-gate.js +0 -3
- package/lib/modules/series.js +54 -0
- package/lib/modules/switch.js +16 -0
- package/lib/py-bridge.js +81 -15
- package/package.json +1 -1
- package/python/biocore_memory/core/config.py +138 -127
- package/python/biocore_memory/engine.py +2653 -2476
- package/python/biocore_memory/recall/embedding.py +129 -0
- package/python/biocore_memory/recall/feedback.py +202 -0
- package/python/biocore_model/preference_drift.py +255 -0
- package/python/biocore_model/tests/__init__.py +1 -0
- package/python/biocore_model/tests/core_mode_ui_check.mjs +63 -0
- package/python/biocore_model/tests/core_status_check.mjs +29 -0
- package/python/biocore_model/tests/dom_safe_check.mjs +22 -0
- package/python/biocore_model/tests/leak_samples_run.py +18 -0
- package/python/biocore_model/tests/orb_ring_check.mjs +20 -0
- package/python/biocore_model/tests/panel_html_check.mjs +63 -0
- package/python/biocore_model/tests/persona_flow_check.mjs +17 -0
- package/python/biocore_model/tests/render_guard_check.mjs +19 -0
- package/python/biocore_model/tests/switch_groups_check.mjs +94 -0
- package/python/biocore_model/tests/tabs_state_check.mjs +24 -0
- package/python/biocore_model/tests/test_affective_labor.py +102 -0
- package/python/biocore_model/tests/test_appraisal.py +137 -0
- package/python/biocore_model/tests/test_appraisal_action.py +55 -0
- package/python/biocore_model/tests/test_arch_convergence.py +35 -0
- package/python/biocore_model/tests/test_attachment_behavior.py +95 -0
- package/python/biocore_model/tests/test_attachment_regulation.py +63 -0
- package/python/biocore_model/tests/test_attachment_state.py +29 -0
- package/python/biocore_model/tests/test_attribution.py +56 -0
- package/python/biocore_model/tests/test_attributor.py +108 -0
- package/python/biocore_model/tests/test_autonomic.py +69 -0
- package/python/biocore_model/tests/test_autonomy_accumulator.py +84 -0
- package/python/biocore_model/tests/test_autonomy_affect.py +62 -0
- package/python/biocore_model/tests/test_autonomy_blame.py +44 -0
- package/python/biocore_model/tests/test_autonomy_caps.py +29 -0
- package/python/biocore_model/tests/test_autonomy_check.py +40 -0
- package/python/biocore_model/tests/test_autonomy_clingy.py +41 -0
- package/python/biocore_model/tests/test_autonomy_cooldown_selfcontact.py +138 -0
- package/python/biocore_model/tests/test_autonomy_frustration.py +42 -0
- package/python/biocore_model/tests/test_autonomy_hourly_limit.py +46 -0
- package/python/biocore_model/tests/test_autonomy_loop.py +64 -0
- package/python/biocore_model/tests/test_autonomy_mode.py +37 -0
- package/python/biocore_model/tests/test_autonomy_model.py +60 -0
- package/python/biocore_model/tests/test_autonomy_model_v2.py +68 -0
- package/python/biocore_model/tests/test_autonomy_module_text.py +104 -0
- package/python/biocore_model/tests/test_autonomy_params.py +163 -0
- package/python/biocore_model/tests/test_autonomy_reason.py +34 -0
- package/python/biocore_model/tests/test_autonomy_self.py +153 -0
- package/python/biocore_model/tests/test_autonomy_send_gate.py +38 -0
- package/python/biocore_model/tests/test_autonomy_silence.py +37 -0
- package/python/biocore_model/tests/test_autonomy_status.py +35 -0
- package/python/biocore_model/tests/test_autonomy_test_trigger.py +24 -0
- package/python/biocore_model/tests/test_autonomy_tick.py +148 -0
- package/python/biocore_model/tests/test_autonomy_v4.py +64 -0
- package/python/biocore_model/tests/test_autonomy_v5.py +43 -0
- package/python/biocore_model/tests/test_autonomy_v7.py +48 -0
- package/python/biocore_model/tests/test_autonomy_v_theta.py +85 -0
- package/python/biocore_model/tests/test_batch1_wiring.py +231 -0
- package/python/biocore_model/tests/test_batch2_wiring.py +231 -0
- package/python/biocore_model/tests/test_batch3_wiring.py +223 -0
- package/python/biocore_model/tests/test_bio_tick.py +202 -0
- package/python/biocore_model/tests/test_boundary.py +183 -0
- package/python/biocore_model/tests/test_boundary_gate.py +295 -0
- package/python/biocore_model/tests/test_brain_split.py +99 -0
- package/python/biocore_model/tests/test_co_recall.py +350 -0
- package/python/biocore_model/tests/test_commitment_extractor.py +197 -0
- package/python/biocore_model/tests/test_compassion_fatigue.py +84 -0
- package/python/biocore_model/tests/test_connection_bids.py +69 -0
- package/python/biocore_model/tests/test_core_bridge.py +88 -0
- package/python/biocore_model/tests/test_core_bridge_episode.py +14 -0
- package/python/biocore_model/tests/test_core_bridge_forget.py +17 -0
- package/python/biocore_model/tests/test_core_bridge_identity.py +15 -0
- package/python/biocore_model/tests/test_core_bridge_media.py +17 -0
- package/python/biocore_model/tests/test_core_bridge_relation.py +17 -0
- package/python/biocore_model/tests/test_core_bridge_summary.py +17 -0
- package/python/biocore_model/tests/test_core_client.py +156 -0
- package/python/biocore_model/tests/test_core_commands.py +146 -0
- package/python/biocore_model/tests/test_core_mode.py +161 -0
- package/python/biocore_model/tests/test_core_state.py +240 -0
- package/python/biocore_model/tests/test_criticality.py +61 -0
- package/python/biocore_model/tests/test_cross_session_copy.py +67 -0
- package/python/biocore_model/tests/test_debug_values.py +169 -0
- package/python/biocore_model/tests/test_defense_mechanisms.py +177 -0
- package/python/biocore_model/tests/test_delta_budget.py +157 -0
- package/python/biocore_model/tests/test_diag_trace.py +201 -0
- package/python/biocore_model/tests/test_directives_compat.py +698 -0
- package/python/biocore_model/tests/test_dissonance.py +97 -0
- package/python/biocore_model/tests/test_drift_sign.py +87 -0
- package/python/biocore_model/tests/test_embedding_fallback.py +277 -0
- package/python/biocore_model/tests/test_embedding_local.py +279 -0
- package/python/biocore_model/tests/test_embedding_mode.py +175 -0
- package/python/biocore_model/tests/test_embedding_modes.py +342 -0
- package/python/biocore_model/tests/test_emotion_geometry.py +114 -0
- package/python/biocore_model/tests/test_emotion_probe.py +214 -0
- package/python/biocore_model/tests/test_emotion_probe_concurrency.py +128 -0
- package/python/biocore_model/tests/test_entity_graph.py +198 -0
- package/python/biocore_model/tests/test_entity_resolve.py +446 -0
- package/python/biocore_model/tests/test_envy.py +173 -0
- package/python/biocore_model/tests/test_exit_or_silence.py +209 -0
- package/python/biocore_model/tests/test_expr_guard.py +1 -0
- package/python/biocore_model/tests/test_expr_intent.py +66 -0
- package/python/biocore_model/tests/test_expr_state.py +66 -0
- package/python/biocore_model/tests/test_expr_template.py +71 -0
- package/python/biocore_model/tests/test_expr_tone.py +41 -0
- package/python/biocore_model/tests/test_extreme_scenarios.py +147 -0
- package/python/biocore_model/tests/test_feedback_flow.py +185 -0
- package/python/biocore_model/tests/test_feedback_guard.py +65 -0
- package/python/biocore_model/tests/test_feedback_llm.py +59 -0
- package/python/biocore_model/tests/test_feedback_llm_inferred.py +140 -0
- package/python/biocore_model/tests/test_feedback_memory.py +63 -0
- package/python/biocore_model/tests/test_feedback_user.py +61 -0
- package/python/biocore_model/tests/test_frustration_curve.py +66 -0
- package/python/biocore_model/tests/test_global_bio_state.py +116 -0
- package/python/biocore_model/tests/test_governance.py +181 -0
- package/python/biocore_model/tests/test_granularity.py +133 -0
- package/python/biocore_model/tests/test_graph_extract.py +224 -0
- package/python/biocore_model/tests/test_graph_recall.py +226 -0
- package/python/biocore_model/tests/test_graph_sqlite.py +298 -0
- package/python/biocore_model/tests/test_growth.py +301 -0
- package/python/biocore_model/tests/test_intent_render.py +60 -0
- package/python/biocore_model/tests/test_intero_regulation.py +224 -0
- package/python/biocore_model/tests/test_intero_style.py +146 -0
- package/python/biocore_model/tests/test_item3_numeric.py +105 -0
- package/python/biocore_model/tests/test_item6_numeric.py +116 -0
- package/python/biocore_model/tests/test_layered_memory.py +174 -0
- package/python/biocore_model/tests/test_leak_guard.py +75 -0
- package/python/biocore_model/tests/test_learning_probe.py +93 -0
- package/python/biocore_model/tests/test_life_loop.py +222 -0
- package/python/biocore_model/tests/test_life_loop_10s.py +131 -0
- package/python/biocore_model/tests/test_local_embedding_health.py +258 -0
- package/python/biocore_model/tests/test_memory_affect_encoding.py +79 -0
- package/python/biocore_model/tests/test_memory_backend.py +332 -0
- package/python/biocore_model/tests/test_memory_dedup.py +316 -0
- package/python/biocore_model/tests/test_memory_dual.py +219 -0
- package/python/biocore_model/tests/test_memory_dual_wiring.py +219 -0
- package/python/biocore_model/tests/test_memory_feedback.py +241 -0
- package/python/biocore_model/tests/test_memory_fixes.py +84 -0
- package/python/biocore_model/tests/test_memory_forgetting.py +58 -0
- package/python/biocore_model/tests/test_memory_inject.py +211 -0
- package/python/biocore_model/tests/test_memory_mood_recall.py +118 -0
- package/python/biocore_model/tests/test_memory_store.py +372 -0
- package/python/biocore_model/tests/test_metacog.py +103 -0
- package/python/biocore_model/tests/test_migrate.py +245 -0
- package/python/biocore_model/tests/test_migrate_store.py +194 -0
- package/python/biocore_model/tests/test_mind_wander.py +65 -0
- package/python/biocore_model/tests/test_mood.py +35 -0
- package/python/biocore_model/tests/test_narrative.py +114 -0
- package/python/biocore_model/tests/test_needs_esteem.py +51 -0
- package/python/biocore_model/tests/test_needs_events.py +205 -0
- package/python/biocore_model/tests/test_needs_sleep.py +54 -0
- package/python/biocore_model/tests/test_noise_bank.py +54 -0
- package/python/biocore_model/tests/test_numeric_schema.py +98 -0
- package/python/biocore_model/tests/test_output_protocol.py +65 -0
- package/python/biocore_model/tests/test_page_debug.py +147 -0
- package/python/biocore_model/tests/test_page_status_autonomy.py +215 -0
- package/python/biocore_model/tests/test_pipeline.py +345 -0
- package/python/biocore_model/tests/test_pipeline_api.py +192 -0
- package/python/biocore_model/tests/test_pipeline_mode.py +222 -0
- package/python/biocore_model/tests/test_plugin_config.py +85 -0
- package/python/biocore_model/tests/test_policy_learning.py +116 -0
- package/python/biocore_model/tests/test_possible_selves.py +88 -0
- package/python/biocore_model/tests/test_predictor.py +227 -0
- package/python/biocore_model/tests/test_preference_drift.py +142 -0
- package/python/biocore_model/tests/test_preset.py +142 -0
- package/python/biocore_model/tests/test_proactive_context.py +70 -0
- package/python/biocore_model/tests/test_proactive_history.py +90 -0
- package/python/biocore_model/tests/test_proactive_memory.py +141 -0
- package/python/biocore_model/tests/test_proactive_send_record.py +88 -0
- package/python/biocore_model/tests/test_probe_contract.py +71 -0
- package/python/biocore_model/tests/test_prospective.py +154 -0
- package/python/biocore_model/tests/test_provider_fallback.py +89 -0
- package/python/biocore_model/tests/test_reconstruction.py +111 -0
- package/python/biocore_model/tests/test_regulation_strategies.py +228 -0
- package/python/biocore_model/tests/test_relation_stage.py +114 -0
- package/python/biocore_model/tests/test_relationship_stage.py +59 -0
- package/python/biocore_model/tests/test_release_140.py +55 -0
- package/python/biocore_model/tests/test_repair_after_boundary.py +249 -0
- package/python/biocore_model/tests/test_reply_limit.py +86 -0
- package/python/biocore_model/tests/test_reply_limit_persist.py +48 -0
- package/python/biocore_model/tests/test_reply_split.py +52 -0
- package/python/biocore_model/tests/test_reply_split_astrbot.py +66 -0
- package/python/biocore_model/tests/test_reply_split_inject.py +53 -0
- package/python/biocore_model/tests/test_rerank.py +229 -0
- package/python/biocore_model/tests/test_rollback.py +175 -0
- package/python/biocore_model/tests/test_runtime.py +175 -0
- package/python/biocore_model/tests/test_runtime_active.py +205 -0
- package/python/biocore_model/tests/test_runtime_pipeline.py +1583 -0
- package/python/biocore_model/tests/test_runtime_proactive.py +231 -0
- package/python/biocore_model/tests/test_scheduler.py +79 -0
- package/python/biocore_model/tests/test_secrecy.py +105 -0
- package/python/biocore_model/tests/test_self_esteem.py +45 -0
- package/python/biocore_model/tests/test_self_intent.py +347 -0
- package/python/biocore_model/tests/test_self_interest.py +183 -0
- package/python/biocore_model/tests/test_self_layer_wiring.py +198 -0
- package/python/biocore_model/tests/test_self_verification.py +113 -0
- package/python/biocore_model/tests/test_situation.py +142 -0
- package/python/biocore_model/tests/test_sleep_circadian.py +41 -0
- package/python/biocore_model/tests/test_social_pain.py +49 -0
- package/python/biocore_model/tests/test_social_repair.py +67 -0
- package/python/biocore_model/tests/test_social_theory.py +78 -0
- package/python/biocore_model/tests/test_source_monitoring.py +204 -0
- package/python/biocore_model/tests/test_speak_like_group.py +36 -0
- package/python/biocore_model/tests/test_stages.py +226 -0
- package/python/biocore_model/tests/test_state_tag.py +86 -0
- package/python/biocore_model/tests/test_template_expr.py +65 -0
- package/python/biocore_model/tests/test_text_inference.py +70 -0
- package/python/biocore_model/tests/test_theta_params.py +115 -0
- package/python/biocore_model/tests/test_topic_control.py +200 -0
- package/python/biocore_model/tests/test_turn_memory.py +105 -0
- package/python/biocore_model/tests/test_turn_memory_panel.py +174 -0
- package/python/biocore_model/tests/test_ui_settings.py +84 -0
- package/python/biocore_model/tests/test_ui_settings_authority.py +179 -0
- package/python/biocore_model/tests/test_user_appraisal_async.py +155 -0
- package/python/biocore_model/tests/test_user_classify.py +122 -0
- package/python/biocore_model/tests/test_value_order.py +117 -0
- package/python/biocore_model/tests/test_voice_bank.py +105 -0
- package/python/biocore_model/tests/test_waiting_sense.py +216 -0
- package/python/biocore_model/tests/test_x_directives.py +101 -0
- package/python/biocore_model/tests/ui_compat_check.mjs +22 -0
- package/python/biocore_model/tests/view_mode_check.mjs +17 -0
- package/python/biocore_runtime/autonomy.py +208 -0
- package/python/biocore_runtime/core_state.py +302 -0
- package/python/biocore_runtime/governance.py +495 -0
- package/python/biocore_runtime/growth.py +528 -0
- package/python/biocore_runtime/pipeline.py +850 -23
- package/python/biocore_runtime/relationship_state.py +214 -0
- package/python/biocore_runtime/self_layer.py +103 -0
- package/python/biocore_runtime/service.py +162 -0
- package/python/biocore_runtime/standby.py +472 -0
- package/ui/panel-layout.js +83 -39
- package/ui/panel.css +885 -743
- package/ui/panel.html +356 -296
- package/ui/panel.js +739 -865
|
@@ -25,6 +25,17 @@ except Exception: # pragma: no cover - top-level/standalone tests
|
|
|
25
25
|
from embedding_mode import resolve_mode # type: ignore
|
|
26
26
|
|
|
27
27
|
EMBED_CACHE_MAX = 512
|
|
28
|
+
|
|
29
|
+
# task-221: observable cache/provider counters (process-local, never raises).
|
|
30
|
+
EMBED_STATS = {"hit": 0, "disk_hit": 0, "miss": 0, "remote": 0, "local": 0, "fail": 0}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def embed_stats(reset=False):
|
|
34
|
+
data = dict(EMBED_STATS)
|
|
35
|
+
if reset:
|
|
36
|
+
for key in EMBED_STATS:
|
|
37
|
+
EMBED_STATS[key] = 0
|
|
38
|
+
return data
|
|
28
39
|
EMBED_TEXT_MAX_CHARS = 2000
|
|
29
40
|
DEFAULT_TIMEOUT, MIN_TIMEOUT, MAX_TIMEOUT = 5.0, 0.1, 60.0
|
|
30
41
|
ENV_BASE_URL = "BIOCORE_EMBEDDING_BASE_URL"
|
|
@@ -105,6 +116,104 @@ def _validate_vector(value) -> Optional[List[float]]:
|
|
|
105
116
|
return out
|
|
106
117
|
|
|
107
118
|
|
|
119
|
+
CACHE_FILE_ENV = "BIOCORE_EMBEDDING_CACHE_FILE"
|
|
120
|
+
STATS_FILE_ENV = "BIOCORE_EMBED_STATS_FILE"
|
|
121
|
+
_STATS_FLUSHED = {}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _stats_file():
|
|
125
|
+
return os.environ.get(STATS_FILE_ENV) or None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _flush_stats():
|
|
129
|
+
"""Persist counters so /stats keeps counting across spawn-per-call.
|
|
130
|
+
|
|
131
|
+
Each bridge call is a fresh process; the file accumulates deltas since the
|
|
132
|
+
last flush of this process (never raises, never blocks a turn).
|
|
133
|
+
"""
|
|
134
|
+
path = _stats_file()
|
|
135
|
+
if not path:
|
|
136
|
+
return
|
|
137
|
+
try:
|
|
138
|
+
previous = {}
|
|
139
|
+
if os.path.isfile(path):
|
|
140
|
+
try:
|
|
141
|
+
with open(path, encoding="utf-8") as handle:
|
|
142
|
+
loaded = json.load(handle)
|
|
143
|
+
if isinstance(loaded, dict):
|
|
144
|
+
previous = loaded
|
|
145
|
+
except Exception:
|
|
146
|
+
previous = {}
|
|
147
|
+
merged = {}
|
|
148
|
+
for key in EMBED_STATS:
|
|
149
|
+
try:
|
|
150
|
+
before = int(previous.get(key, 0) or 0)
|
|
151
|
+
except Exception:
|
|
152
|
+
before = 0
|
|
153
|
+
merged[key] = before + EMBED_STATS[key] - _STATS_FLUSHED.get(key, 0)
|
|
154
|
+
directory = os.path.dirname(path)
|
|
155
|
+
if directory:
|
|
156
|
+
os.makedirs(directory, exist_ok=True)
|
|
157
|
+
tmp = path + ".tmp"
|
|
158
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
159
|
+
json.dump(merged, handle)
|
|
160
|
+
os.replace(tmp, path)
|
|
161
|
+
_STATS_FLUSHED.update(EMBED_STATS)
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _cache_file():
|
|
167
|
+
path = os.environ.get(CACHE_FILE_ENV) or ""
|
|
168
|
+
if not path:
|
|
169
|
+
return None
|
|
170
|
+
try:
|
|
171
|
+
os.makedirs(path, exist_ok=True)
|
|
172
|
+
return os.path.join(path, "embedding-cache.jsonl")
|
|
173
|
+
except Exception:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _disk_lookup(file_path, key):
|
|
178
|
+
if not file_path or not os.path.isfile(file_path):
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
with open(file_path, encoding="utf-8") as fh:
|
|
182
|
+
for line in fh:
|
|
183
|
+
try:
|
|
184
|
+
row = json.loads(line)
|
|
185
|
+
except Exception:
|
|
186
|
+
continue
|
|
187
|
+
if row.get("k") == key:
|
|
188
|
+
return _validate_vector(row.get("v"))
|
|
189
|
+
except Exception:
|
|
190
|
+
return None
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _disk_put(file_path, key, vector, keep=512):
|
|
195
|
+
if not file_path:
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
rows = []
|
|
199
|
+
if os.path.isfile(file_path):
|
|
200
|
+
with open(file_path, encoding="utf-8") as fh:
|
|
201
|
+
for line in fh:
|
|
202
|
+
try:
|
|
203
|
+
rows.append(json.loads(line))
|
|
204
|
+
except Exception:
|
|
205
|
+
continue
|
|
206
|
+
rows = [r for r in rows if r.get("k") != key]
|
|
207
|
+
rows.append({"k": key, "v": list(vector)})
|
|
208
|
+
rows = rows[-keep:]
|
|
209
|
+
with open(file_path + ".tmp", "w", encoding="utf-8") as fh:
|
|
210
|
+
for row in rows:
|
|
211
|
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
212
|
+
os.replace(file_path + ".tmp", file_path)
|
|
213
|
+
except Exception:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
|
|
108
217
|
def _cache_get(cache, key):
|
|
109
218
|
if cache is None:
|
|
110
219
|
return None
|
|
@@ -719,6 +828,13 @@ def _call_provider(provider, text, timeout):
|
|
|
719
828
|
|
|
720
829
|
def embed(text, provider, cache=None, timeout=None):
|
|
721
830
|
"""Return a vector for text, or None (never raises); LRU cache optional."""
|
|
831
|
+
try:
|
|
832
|
+
return _embed_impl(text, provider, cache, timeout)
|
|
833
|
+
finally:
|
|
834
|
+
_flush_stats()
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _embed_impl(text, provider, cache=None, timeout=None):
|
|
722
838
|
if provider is None:
|
|
723
839
|
return None
|
|
724
840
|
normalized = normalize_text(text)
|
|
@@ -727,19 +843,32 @@ def embed(text, provider, cache=None, timeout=None):
|
|
|
727
843
|
key = cache_key(normalized)
|
|
728
844
|
cached = _cache_get(cache, key)
|
|
729
845
|
if cached is not None:
|
|
846
|
+
EMBED_STATS["hit"] += 1
|
|
730
847
|
try: # true LRU when the caller passes an OrderedDict
|
|
731
848
|
cache.move_to_end(key)
|
|
732
849
|
except Exception:
|
|
733
850
|
pass
|
|
734
851
|
return cached
|
|
852
|
+
disk = _cache_file()
|
|
853
|
+
from_disk = _disk_lookup(disk, key) if disk else None
|
|
854
|
+
if from_disk is not None:
|
|
855
|
+
EMBED_STATS["disk_hit"] += 1
|
|
856
|
+
_cache_put(cache, key, from_disk)
|
|
857
|
+
return from_disk
|
|
858
|
+
EMBED_STATS["miss"] += 1
|
|
735
859
|
checked = _validate_vector(
|
|
736
860
|
_call_provider(provider, normalized, _timeout_for(provider, timeout)))
|
|
737
861
|
if checked is not None:
|
|
862
|
+
EMBED_STATS["remote"] += 1
|
|
738
863
|
_cache_put(cache, key, checked)
|
|
864
|
+
_disk_put(disk, key, checked)
|
|
739
865
|
return checked
|
|
740
866
|
# Remote failed / timed out / missing key: try the local CPU int8 model.
|
|
741
867
|
local = _fallback_embed(provider, normalized)
|
|
742
868
|
if local is None:
|
|
869
|
+
EMBED_STATS["fail"] += 1
|
|
743
870
|
return None
|
|
871
|
+
EMBED_STATS["local"] += 1
|
|
744
872
|
_cache_put(cache, key, local)
|
|
873
|
+
_disk_put(disk, key, local)
|
|
745
874
|
return local
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""task-218: recall feedback into affect ("回忆回灌情绪").
|
|
3
|
+
|
|
4
|
+
A recalled memory already carries the affect it was encoded with (task-217,
|
|
5
|
+
entry["affect"]). Recall may feed a small share of that stored affect back
|
|
6
|
+
into the live affect state, so remembering a warm moment lifts the mood a
|
|
7
|
+
little and remembering a bad one pulls it down -- then stops.
|
|
8
|
+
|
|
9
|
+
Three guards, all from core/config.py:
|
|
10
|
+
|
|
11
|
+
CAP per-turn |delta| <= FEEDBACK_MAX_STEP, valence and arousal apart
|
|
12
|
+
COOLDOWN the same entry cannot feed twice inside FEEDBACK_COOLDOWN seconds
|
|
13
|
+
(the caller owns the {entry_id: last_ts} map)
|
|
14
|
+
FLOOR at valence <= FEEDBACK_NEG_FLOOR a further negative pull is
|
|
15
|
+
dropped to 0 and the caller is told (blocked_negative) to run a
|
|
16
|
+
regulation strategy instead
|
|
17
|
+
|
|
18
|
+
Pure: no IO, no clock, no host import, never touches the store. The engine
|
|
19
|
+
passes the rows it actually injected (top-k), the current affect and now,
|
|
20
|
+
so one recall can be replayed exactly in a test.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from ..core.config import (FEEDBACK_COOLDOWN, FEEDBACK_GAIN,
|
|
25
|
+
FEEDBACK_MAX_STEP, FEEDBACK_NEG_FLOOR)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _num(value, default=0.0):
|
|
29
|
+
try:
|
|
30
|
+
number = float(value)
|
|
31
|
+
except Exception:
|
|
32
|
+
return default
|
|
33
|
+
return number if number == number else default
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _clamp(value, low, high):
|
|
37
|
+
return max(low, min(high, _num(value, low)))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _pair(source):
|
|
41
|
+
"""(valence, arousal) from a dict, a 2-sequence or an object."""
|
|
42
|
+
if isinstance(source, dict):
|
|
43
|
+
v = source.get("v", source.get("valence", 0.0))
|
|
44
|
+
a = source.get("a", source.get("arousal", 0.5))
|
|
45
|
+
elif isinstance(source, (list, tuple)) and len(source) >= 2:
|
|
46
|
+
v, a = source[0], source[1]
|
|
47
|
+
else:
|
|
48
|
+
v = getattr(source, "v", getattr(source, "valence", 0.0))
|
|
49
|
+
a = getattr(source, "a", getattr(source, "arousal", 0.5))
|
|
50
|
+
return _clamp(_num(v, 0.0), -1.0, 1.0), _clamp(_num(a, 0.5), 0.0, 1.0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _entry_of(row):
|
|
54
|
+
if isinstance(row, dict):
|
|
55
|
+
entry = row.get("entry")
|
|
56
|
+
if isinstance(entry, dict):
|
|
57
|
+
return entry
|
|
58
|
+
return row if isinstance(row, dict) else {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stored_affect(entry):
|
|
62
|
+
"""(valence, arousal) stored at write time, or None when absent."""
|
|
63
|
+
if not isinstance(entry, dict):
|
|
64
|
+
return None
|
|
65
|
+
affect = entry.get("affect")
|
|
66
|
+
if not isinstance(affect, dict):
|
|
67
|
+
return None
|
|
68
|
+
if "valence" not in affect and "v" not in affect:
|
|
69
|
+
return None
|
|
70
|
+
raw_v = affect.get("valence", affect.get("v"))
|
|
71
|
+
raw_a = affect.get("arousal", affect.get("a", 0.5))
|
|
72
|
+
try:
|
|
73
|
+
valid_v = float(raw_v)
|
|
74
|
+
valid_a = float(raw_a)
|
|
75
|
+
except Exception:
|
|
76
|
+
return None
|
|
77
|
+
if valid_v != valid_v or valid_a != valid_a: # NaN
|
|
78
|
+
return None
|
|
79
|
+
return (_clamp(valid_v, -1.0, 1.0), _clamp(valid_a, 0.0, 1.0))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def key_of(entry):
|
|
83
|
+
"""Stable cooldown key: the entry id, else a text prefix."""
|
|
84
|
+
entry = entry if isinstance(entry, dict) else {}
|
|
85
|
+
entry_id = str(entry.get("id") or "").strip()
|
|
86
|
+
if entry_id:
|
|
87
|
+
return entry_id
|
|
88
|
+
return "text:" + str(entry.get("text") or "")[:40]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _weight(entry):
|
|
92
|
+
return _clamp(entry.get("importance", 0.5) if isinstance(entry, dict) else 0.5,
|
|
93
|
+
0.0, 1.0)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def plan(rows, current, cooldowns=None, now=0.0, budget=None,
|
|
97
|
+
gain=None, max_step=None, cooldown=None, neg_floor=None):
|
|
98
|
+
"""Decide the per-turn affect nudge for one recall batch.
|
|
99
|
+
|
|
100
|
+
rows what this recall injected (row dicts or raw entries); trimmed
|
|
101
|
+
to budget when given
|
|
102
|
+
current live affect: dict {v/a | valence/arousal}, (v, a) or object
|
|
103
|
+
cooldowns {entry key: last feedback ts}; read only, the caller updates it
|
|
104
|
+
"""
|
|
105
|
+
cooldowns = cooldowns if isinstance(cooldowns, dict) else {}
|
|
106
|
+
now = _num(now, 0.0)
|
|
107
|
+
gain = _clamp(FEEDBACK_GAIN if gain is None else gain, 0.0, 1.0)
|
|
108
|
+
max_step = max(0.0, _num(FEEDBACK_MAX_STEP if max_step is None else max_step,
|
|
109
|
+
FEEDBACK_MAX_STEP))
|
|
110
|
+
cool = max(0.0, _num(FEEDBACK_COOLDOWN if cooldown is None else cooldown,
|
|
111
|
+
FEEDBACK_COOLDOWN))
|
|
112
|
+
floor = _clamp(FEEDBACK_NEG_FLOOR if neg_floor is None else neg_floor,
|
|
113
|
+
-1.0, 1.0)
|
|
114
|
+
before_v, before_a = _pair(current)
|
|
115
|
+
|
|
116
|
+
batch = [row for row in (rows or []) if isinstance(row, dict)]
|
|
117
|
+
if budget is not None:
|
|
118
|
+
try:
|
|
119
|
+
batch = batch[:max(1, int(budget))]
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
skipped = []
|
|
124
|
+
eligible = [] # (key, (v, a), weight)
|
|
125
|
+
for row in batch:
|
|
126
|
+
entry = _entry_of(row)
|
|
127
|
+
key = key_of(entry)
|
|
128
|
+
pair = stored_affect(entry)
|
|
129
|
+
if pair is None:
|
|
130
|
+
skipped.append({"id": key, "reason": "no_affect"})
|
|
131
|
+
continue
|
|
132
|
+
last = cooldowns.get(key)
|
|
133
|
+
if last is not None and now - _num(last, 0.0) < cool:
|
|
134
|
+
skipped.append({"id": key, "reason": "cooldown"})
|
|
135
|
+
continue
|
|
136
|
+
eligible.append((key, pair, _weight(entry)))
|
|
137
|
+
|
|
138
|
+
report = {
|
|
139
|
+
"enabled": True,
|
|
140
|
+
"before": [round(before_v, 4), round(before_a, 4)],
|
|
141
|
+
"target": None,
|
|
142
|
+
"delta": [0.0, 0.0],
|
|
143
|
+
"after": [round(before_v, 4), round(before_a, 4)],
|
|
144
|
+
"eligible": [key for key, _value, _weight_value in eligible],
|
|
145
|
+
"applied": [],
|
|
146
|
+
"skipped": skipped,
|
|
147
|
+
"capped": False,
|
|
148
|
+
"blocked_negative": False,
|
|
149
|
+
"regulation": None,
|
|
150
|
+
"reason": "",
|
|
151
|
+
}
|
|
152
|
+
if not eligible:
|
|
153
|
+
report["reason"] = "no_eligible_memory"
|
|
154
|
+
return report
|
|
155
|
+
|
|
156
|
+
total_w = sum(weight for _key, _value, weight in eligible)
|
|
157
|
+
if total_w <= 0:
|
|
158
|
+
total_w = float(len(eligible))
|
|
159
|
+
weights = [1.0] * len(eligible)
|
|
160
|
+
else:
|
|
161
|
+
weights = [weight for _key, _value, weight in eligible]
|
|
162
|
+
target_v = sum(pair[0] * weight
|
|
163
|
+
for (_key, pair, _weight_value), weight
|
|
164
|
+
in zip(eligible, weights)) / total_w
|
|
165
|
+
target_a = sum(pair[1] * weight
|
|
166
|
+
for (_key, pair, _weight_value), weight
|
|
167
|
+
in zip(eligible, weights)) / total_w
|
|
168
|
+
target_v = _clamp(target_v, -1.0, 1.0)
|
|
169
|
+
target_a = _clamp(target_a, 0.0, 1.0)
|
|
170
|
+
|
|
171
|
+
raw_v = (target_v - before_v) * gain
|
|
172
|
+
raw_a = (target_a - before_a) * gain
|
|
173
|
+
delta_v = max(-max_step, min(max_step, raw_v))
|
|
174
|
+
delta_a = max(-max_step, min(max_step, raw_a))
|
|
175
|
+
report["capped"] = bool(abs(raw_v) > max_step + 1e-12
|
|
176
|
+
or abs(raw_a) > max_step + 1e-12)
|
|
177
|
+
|
|
178
|
+
if delta_v < 0 and before_v <= floor:
|
|
179
|
+
# Already low: do not push further down; regulation takes over.
|
|
180
|
+
delta_v = 0.0
|
|
181
|
+
report["blocked_negative"] = True
|
|
182
|
+
report["reason"] = "negative_floor"
|
|
183
|
+
|
|
184
|
+
report["target"] = [round(target_v, 4), round(target_a, 4)]
|
|
185
|
+
report["delta"] = [round(delta_v, 6), round(delta_a, 6)]
|
|
186
|
+
report["after"] = [round(_clamp(before_v + delta_v, -1.0, 1.0), 4),
|
|
187
|
+
round(_clamp(before_a + delta_a, 0.0, 1.0), 4)]
|
|
188
|
+
if delta_v or delta_a:
|
|
189
|
+
report["applied"] = list(report["eligible"])
|
|
190
|
+
report["reason"] = report["reason"] or "ok"
|
|
191
|
+
else:
|
|
192
|
+
report["reason"] = report["reason"] or "no_change"
|
|
193
|
+
return report
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def enabled(flags=None, env=None):
|
|
197
|
+
"""Flag or env switch; both default off (old behaviour)."""
|
|
198
|
+
flags = flags if isinstance(flags, dict) else {}
|
|
199
|
+
if flags.get("memory_feedback") is True:
|
|
200
|
+
return True
|
|
201
|
+
raw = env.get("BIOCORE_MEMORY_FEEDBACK") if hasattr(env, "get") else None
|
|
202
|
+
return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""task-219: repeated topics slowly drift the value_order weights.
|
|
3
|
+
|
|
4
|
+
Memory already knows what keeps coming back. This module counts those topics
|
|
5
|
+
with the tokenizer that already ships in biocore_model (memory_store.bigrams
|
|
6
|
+
for CJK, a latin-word regex), maps the ones with a known dimension onto
|
|
7
|
+
value_order, and -- only once a topic has appeared in more than
|
|
8
|
+
TOPIC_THRESHOLD memory entries -- nudges that dimension by a small amount per
|
|
9
|
+
new occurrence.
|
|
10
|
+
|
|
11
|
+
Guards, all here so a theme can never rewrite her in one night:
|
|
12
|
+
|
|
13
|
+
THRESHOLD cumulative occurrences must pass TOPIC_THRESHOLD (5)
|
|
14
|
+
PER CALL one step moves a dimension by at most DAILY_CAP (0.01), which is
|
|
15
|
+
also the per-day allowance; callers run one step per day
|
|
16
|
+
BOUNDS every weight stays inside value_order.WEIGHT_FLOOR..CEIL
|
|
17
|
+
WATERMARK only occurrences since the previous call drift the table
|
|
18
|
+
(last_counts), so replaying the same batch is idempotent
|
|
19
|
+
DECAY a dimension whose topic stopped being active relaxes toward
|
|
20
|
+
value_order.DEFAULTS by DECAY_PER_DAY per elapsed day
|
|
21
|
+
LAYERS biocore_model only; never writes a memory entry, never touches
|
|
22
|
+
the persona file, never renders prompt text
|
|
23
|
+
|
|
24
|
+
Pure functions only: no IO, no clock, no host imports, no LLM calls. The
|
|
25
|
+
caller passes weights, counts and the last watermark, so a drift step can be
|
|
26
|
+
replayed exactly in a test. Default OFF: flags {"preference_drift": True} or
|
|
27
|
+
env BIOCORE_PREFERENCE_DRIFT=1.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from . import value_order as _vo
|
|
35
|
+
except Exception: # pragma: no cover - degraded checkout
|
|
36
|
+
_vo = None
|
|
37
|
+
|
|
38
|
+
TOPIC_THRESHOLD = 5
|
|
39
|
+
PER_REPEAT = 0.002
|
|
40
|
+
DAILY_CAP = 0.01
|
|
41
|
+
DECAY_PER_DAY = 0.0005
|
|
42
|
+
MAX_TOPICS = 24
|
|
43
|
+
|
|
44
|
+
_WS = re.compile(r"\s+")
|
|
45
|
+
_LATIN = re.compile(r"[a-z][a-z0-9_']*")
|
|
46
|
+
_CJK = re.compile(r"[\u4e00-\u9fff]+")
|
|
47
|
+
|
|
48
|
+
# topic keyword -> value_order dimension; the longest matching keyword wins.
|
|
49
|
+
TOPIC_DIMENSIONS = (
|
|
50
|
+
("self_direction", ("自由", "自己做主", "自己决定", "独立", "选择", "决定")),
|
|
51
|
+
("stimulation", ("新鲜", "刺激", "旅行", "冒险", "出去玩", "尝试")),
|
|
52
|
+
("hedonism", ("开心", "好吃", "吃", "玩", "舒服", "放松")),
|
|
53
|
+
("achievement", ("认可", "夸", "厉害", "成功", "进步", "成绩")),
|
|
54
|
+
("power", ("掌控", "主导", "说话算数", "权力", "命令")),
|
|
55
|
+
("security", ("安全", "害怕", "担心", "不安", "稳定")),
|
|
56
|
+
("conformity", ("规矩", "礼貌", "应该", "不能", "越界")),
|
|
57
|
+
("tradition", ("传统", "习惯", "以前", "老规矩")),
|
|
58
|
+
("benevolence", ("陪", "需要我", "帮你", "关心", "照顾", "倾听")),
|
|
59
|
+
("universalism", ("公平", "诚实", "尊重", "正义", "原则")),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
STOPWORDS = frozenset((
|
|
63
|
+
"我们", "你们", "他们", "她们", "这个", "那个", "就是", "不是", "一个",
|
|
64
|
+
"什么", "怎么", "可以", "没有", "因为", "所以", "然后", "还是", "现在",
|
|
65
|
+
"今天", "明天", "知道", "觉得", "自己", "已经", "这样", "那样", "东西",
|
|
66
|
+
))
|
|
67
|
+
FALLBACK_DEFAULTS = {
|
|
68
|
+
"self_direction": 0.62, "stimulation": 0.45, "hedonism": 0.40,
|
|
69
|
+
"achievement": 0.55, "power": 0.38, "security": 0.70,
|
|
70
|
+
"conformity": 0.35, "tradition": 0.30, "benevolence": 0.75,
|
|
71
|
+
"universalism": 0.60,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _num(value, default=0.0):
|
|
76
|
+
try:
|
|
77
|
+
number = float(value)
|
|
78
|
+
except Exception:
|
|
79
|
+
return default
|
|
80
|
+
return number if number == number else default
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _clamp(value, low, high):
|
|
84
|
+
return max(low, min(high, _num(value, low)))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _dimensions():
|
|
88
|
+
return tuple(_vo.DIMENSIONS) if _vo is not None else tuple(FALLBACK_DEFAULTS)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _defaults():
|
|
92
|
+
return dict(_vo.DEFAULTS) if _vo is not None else dict(FALLBACK_DEFAULTS)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def enabled(flags=None, env=None):
|
|
96
|
+
"""Flag or env switch; both default OFF (old behaviour)."""
|
|
97
|
+
flags = flags if isinstance(flags, dict) else {}
|
|
98
|
+
if flags.get("preference_drift") is True:
|
|
99
|
+
return True
|
|
100
|
+
raw = env.get("BIOCORE_PREFERENCE_DRIFT") if hasattr(env, "get") else None
|
|
101
|
+
return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def topics(text, limit=MAX_TOPICS):
|
|
105
|
+
"""Topic tokens for one memory text (CJK bigrams + latin words)."""
|
|
106
|
+
lowered = _WS.sub(" ", str(text or "").lower())
|
|
107
|
+
out = []
|
|
108
|
+
seen = set()
|
|
109
|
+
for run in _CJK.findall(lowered):
|
|
110
|
+
if len(run) < 2:
|
|
111
|
+
continue
|
|
112
|
+
for index in range(len(run) - 1):
|
|
113
|
+
cand = run[index:index + 2]
|
|
114
|
+
if cand in seen or cand in STOPWORDS:
|
|
115
|
+
continue
|
|
116
|
+
seen.add(cand)
|
|
117
|
+
out.append(cand)
|
|
118
|
+
if len(out) >= limit:
|
|
119
|
+
return out
|
|
120
|
+
for word in _LATIN.findall(lowered):
|
|
121
|
+
if len(word) < 3 or word in seen or word in STOPWORDS:
|
|
122
|
+
continue
|
|
123
|
+
seen.add(word)
|
|
124
|
+
out.append(word)
|
|
125
|
+
if len(out) >= limit:
|
|
126
|
+
break
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def dimension_of(topic):
|
|
131
|
+
"""value_order dimension for a topic, or None (longest keyword wins)."""
|
|
132
|
+
key = str(topic or "").lower()
|
|
133
|
+
if not key:
|
|
134
|
+
return None
|
|
135
|
+
best = None
|
|
136
|
+
best_len = 0
|
|
137
|
+
for dim, keywords in TOPIC_DIMENSIONS:
|
|
138
|
+
for word in keywords:
|
|
139
|
+
if word in key and len(word) > best_len:
|
|
140
|
+
best, best_len = dim, len(word)
|
|
141
|
+
return best
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def accumulate(counts=None, entries=None):
|
|
145
|
+
"""Add one count per entry per topic; returns a new dict (never mutates)."""
|
|
146
|
+
table = {}
|
|
147
|
+
if isinstance(counts, dict):
|
|
148
|
+
for key, value in counts.items():
|
|
149
|
+
table[str(key)] = int(_num(value, 0))
|
|
150
|
+
for entry in entries or []:
|
|
151
|
+
text = entry.get("text") if isinstance(entry, dict) else str(entry or "")
|
|
152
|
+
if not text:
|
|
153
|
+
continue
|
|
154
|
+
for topic in set(topics(text)):
|
|
155
|
+
table[topic] = table.get(topic, 0) + 1
|
|
156
|
+
return table
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def count_topics(entries):
|
|
160
|
+
"""Cumulative topic counts for a whole memory list."""
|
|
161
|
+
return accumulate({}, entries)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def decay_counts(counts=None, days=0.0, half_life_days=60.0):
|
|
165
|
+
"""Halve the counts once per half-life; keeps a fading theme fading."""
|
|
166
|
+
days = max(0.0, _num(days, 0.0))
|
|
167
|
+
half_life = max(1e-6, _num(half_life_days, 60.0))
|
|
168
|
+
factor = 0.5 ** (days / half_life)
|
|
169
|
+
table = {}
|
|
170
|
+
if isinstance(counts, dict):
|
|
171
|
+
for key, value in counts.items():
|
|
172
|
+
table[str(key)] = round(max(0.0, _num(value, 0.0)) * factor, 4)
|
|
173
|
+
return table
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def step(weights=None, counts=None, last_counts=None, days=1.0,
|
|
177
|
+
enabled=True, threshold=TOPIC_THRESHOLD, per_repeat=PER_REPEAT,
|
|
178
|
+
daily_cap=DAILY_CAP, decay_per_day=DECAY_PER_DAY):
|
|
179
|
+
"""One drift step. Call once per day with the counts seen so far.
|
|
180
|
+
|
|
181
|
+
weights the current value table (normalized copy is returned)
|
|
182
|
+
counts cumulative topic occurrences in memory
|
|
183
|
+
last_counts the counts watermark from the previous step
|
|
184
|
+
days elapsed days since the previous step (decay only)
|
|
185
|
+
"""
|
|
186
|
+
if _vo is None:
|
|
187
|
+
return {"enabled": bool(enabled), "weights": dict(weights or {}),
|
|
188
|
+
"drifted": {}, "decayed": [], "active": {}, "counts": {},
|
|
189
|
+
"reason": "value_order_unavailable"}
|
|
190
|
+
base = _vo.normalize(weights)
|
|
191
|
+
result = {"enabled": bool(enabled), "weights": base, "drifted": {},
|
|
192
|
+
"decayed": [], "active": {}, "counts": {}, "reason": "ok"}
|
|
193
|
+
if not enabled:
|
|
194
|
+
result["reason"] = "disabled"
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
counts = counts if isinstance(counts, dict) else {}
|
|
198
|
+
last = last_counts if isinstance(last_counts, dict) else {}
|
|
199
|
+
days = max(0.0, _num(days, 1.0))
|
|
200
|
+
threshold = max(1, int(_num(threshold, TOPIC_THRESHOLD)))
|
|
201
|
+
per_repeat = _clamp(per_repeat, 0.0, DAILY_CAP)
|
|
202
|
+
cap = _clamp(daily_cap, 0.0, 0.05)
|
|
203
|
+
decay = _clamp(decay_per_day, 0.0, DAILY_CAP)
|
|
204
|
+
dimensions = _dimensions()
|
|
205
|
+
defaults = _defaults()
|
|
206
|
+
|
|
207
|
+
fresh = {}
|
|
208
|
+
active = {}
|
|
209
|
+
for topic, total in counts.items():
|
|
210
|
+
total = _num(total, 0.0)
|
|
211
|
+
if total <= threshold:
|
|
212
|
+
continue
|
|
213
|
+
active[str(topic)] = total
|
|
214
|
+
new = max(0.0, total - _num(last.get(topic), 0.0))
|
|
215
|
+
if new:
|
|
216
|
+
fresh[str(topic)] = new
|
|
217
|
+
result["active"] = active
|
|
218
|
+
|
|
219
|
+
by_dim = {}
|
|
220
|
+
for topic, new in fresh.items():
|
|
221
|
+
dim = dimension_of(topic)
|
|
222
|
+
if dim is None:
|
|
223
|
+
continue
|
|
224
|
+
by_dim[dim] = by_dim.get(dim, 0.0) + per_repeat * new
|
|
225
|
+
active_dims = {dimension_of(topic) for topic in active}
|
|
226
|
+
for dim, raw in by_dim.items():
|
|
227
|
+
delta = min(cap, raw)
|
|
228
|
+
if delta <= 0:
|
|
229
|
+
continue
|
|
230
|
+
before = base[dim]
|
|
231
|
+
base[dim] = round(_clamp(before + delta, _vo.WEIGHT_FLOOR,
|
|
232
|
+
_vo.WEIGHT_CEIL), 4)
|
|
233
|
+
moved = round(base[dim] - before, 4)
|
|
234
|
+
if moved:
|
|
235
|
+
result["drifted"][dim] = moved
|
|
236
|
+
|
|
237
|
+
if decay > 0 and days > 0:
|
|
238
|
+
relax = min(cap * days, decay * days)
|
|
239
|
+
for dim in dimensions:
|
|
240
|
+
if dim in active_dims or dim in result["drifted"]:
|
|
241
|
+
continue
|
|
242
|
+
before = base[dim]
|
|
243
|
+
default = _num(defaults.get(dim), before)
|
|
244
|
+
if abs(before - default) <= 1e-9:
|
|
245
|
+
continue
|
|
246
|
+
toward = 1.0 if default > before else -1.0
|
|
247
|
+
base[dim] = round(_clamp(before + toward * min(relax, abs(default - before)),
|
|
248
|
+
_vo.WEIGHT_FLOOR, _vo.WEIGHT_CEIL), 4)
|
|
249
|
+
if abs(base[dim] - before) > 1e-9:
|
|
250
|
+
result["decayed"].append(dim)
|
|
251
|
+
|
|
252
|
+
result["weights"] = base
|
|
253
|
+
result["counts"] = {str(key): _num(value, 0.0)
|
|
254
|
+
for key, value in counts.items()}
|
|
255
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { CORE_MODES, coreModeButtons, describeBridgeError, coreModeConfirmText, coreModeSuccessText, coreBridgeLabel, createCoreModeFlow } from '../core_mode_ui.mjs'
|
|
3
|
+
assert.deepEqual(CORE_MODES, ['off', 'shadow', 'primary'])
|
|
4
|
+
assert.deepEqual(coreModeButtons('off'), { shadow: { disabled: false }, primary: { disabled: false }, rollback: { disabled: true } })
|
|
5
|
+
assert.deepEqual(coreModeButtons('shadow'), { shadow: { disabled: true }, primary: { disabled: false }, rollback: { disabled: false } })
|
|
6
|
+
assert.deepEqual(coreModeButtons('primary'), { shadow: { disabled: false }, primary: { disabled: true }, rollback: { disabled: false } })
|
|
7
|
+
assert.equal(coreModeButtons('bogus').rollback.disabled, false) // unknown mode -> all switches enabled
|
|
8
|
+
assert.equal(coreModeButtons(undefined).shadow.disabled, false)
|
|
9
|
+
assert.equal(coreModeButtons('primary').rollback.disabled, false)
|
|
10
|
+
assert.equal(describeBridgeError({ status: 403, message: 'forbidden' }), 'HTTP 403:forbidden')
|
|
11
|
+
assert.equal(describeBridgeError({ statusCode: 400, code: 'invalid_params', message: 'invalid mode' }), 'HTTP 400 invalid_params:invalid mode')
|
|
12
|
+
assert.equal(describeBridgeError(new Error('boom')), 'boom')
|
|
13
|
+
assert.equal(describeBridgeError({ payload: { code: 'integrity_refused', message: 'refused' } }), 'integrity_refused:refused')
|
|
14
|
+
assert.equal(coreModeConfirmText('shadow'), '确认切到影子模式?')
|
|
15
|
+
assert.equal(coreModeConfirmText('off'), '确认回退到旧版引擎?')
|
|
16
|
+
assert.ok(coreModeConfirmText('primary').includes('核心为主'))
|
|
17
|
+
assert.equal(coreModeSuccessText('shadow'), '已切到影子模式')
|
|
18
|
+
assert.equal(coreModeSuccessText('off'), '已回退旧版引擎')
|
|
19
|
+
assert.equal(coreModeSuccessText('primary', true), '已切到核心模式(重启后生效)')
|
|
20
|
+
assert.equal(coreBridgeLabel(true), '桥接:已连接')
|
|
21
|
+
assert.equal(coreBridgeLabel(false), '桥接:未连接(静态预览)')
|
|
22
|
+
// DOM stub: inline confirm flow (no window.confirm anywhere)
|
|
23
|
+
function makeView({ bridge = true, post = async () => ({ ok: true, mode: 'shadow' }) } = {}) {
|
|
24
|
+
const state = { confirm: null, toast: null, error: null, posts: 0 }
|
|
25
|
+
return {
|
|
26
|
+
state,
|
|
27
|
+
setConfirm(value) { state.confirm = value },
|
|
28
|
+
setToast(value) { state.toast = value },
|
|
29
|
+
setError(value) { state.error = value },
|
|
30
|
+
bridgeAvailable() { return bridge },
|
|
31
|
+
buttons() { return { shadow: { disabled: false }, primary: { disabled: false }, off: { disabled: true } } },
|
|
32
|
+
post(mode) { state.posts += 1; return post(mode) }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const view = makeView()
|
|
36
|
+
const flow = createCoreModeFlow({ post: (mode) => view.post(mode), view })
|
|
37
|
+
const clicked = flow.click('shadow')
|
|
38
|
+
assert.equal(clicked.showConfirm, true)
|
|
39
|
+
assert.equal(view.state.confirm, '确认切到影子模式?')
|
|
40
|
+
assert.equal(flow.click('off').disabled, true) // off button disabled -> no confirm
|
|
41
|
+
flow.cancel()
|
|
42
|
+
assert.equal(view.state.confirm, null)
|
|
43
|
+
assert.equal(flow.calls, 0)
|
|
44
|
+
flow.click('shadow')
|
|
45
|
+
const okRun = await flow.confirm()
|
|
46
|
+
assert.equal(okRun.called, true)
|
|
47
|
+
assert.equal(flow.calls, 1)
|
|
48
|
+
assert.equal(view.state.posts, 1)
|
|
49
|
+
assert.equal(view.state.toast, '已切到影子模式')
|
|
50
|
+
const errView = makeView({ post: async () => { const e = new Error('forbidden'); e.status = 403; throw e } })
|
|
51
|
+
const errFlow = createCoreModeFlow({ post: (mode) => errView.post(mode), view: errView })
|
|
52
|
+
errFlow.click('primary')
|
|
53
|
+
await errFlow.confirm()
|
|
54
|
+
assert.ok(errView.state.toast.includes('HTTP 403'), errView.state.toast)
|
|
55
|
+
assert.ok(errView.state.error.includes('403'))
|
|
56
|
+
const offView = makeView({ bridge: false })
|
|
57
|
+
const offFlow = createCoreModeFlow({ post: (mode) => offView.post(mode), view: offView })
|
|
58
|
+
offFlow.click('shadow')
|
|
59
|
+
const offRun = await offFlow.confirm()
|
|
60
|
+
assert.equal(offRun.called, false)
|
|
61
|
+
assert.ok(offView.state.toast.includes('未连接'), offView.state.toast)
|
|
62
|
+
assert.equal(offView.state.posts, 0)
|
|
63
|
+
console.log('CORE MODE UI OK (23 checks)')
|