dsh-biocore 0.3.3 → 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 (59) hide show
  1. package/lib/index.js +30 -1
  2. package/lib/modules/standby.js +61 -0
  3. package/lib/modules/state.js +22 -3
  4. package/package.json +1 -1
  5. package/python/biocore_memory/core/config.py +7 -3
  6. package/python/biocore_memory/engine.py +91 -6
  7. package/python/biocore_memory/recall/embedding.py +0 -51
  8. package/python/biocore_memory/recall/feedback.py +34 -7
  9. package/python/biocore_memory/recall/fusion.py +5 -1
  10. package/python/biocore_memory/scoring.py +203 -0
  11. package/python/biocore_memory/space.py +355 -0
  12. package/python/biocore_memory/store/dedupe.py +1 -0
  13. package/python/biocore_memory/store/tiering.py +14 -3
  14. package/python/biocore_model/affective_labor.py +14 -1
  15. package/python/biocore_model/appraisal.py +172 -3
  16. package/python/biocore_model/bio_tick.py +43 -13
  17. package/python/biocore_model/emotion_geometry.py +9 -0
  18. package/python/biocore_model/envy.py +21 -0
  19. package/python/biocore_model/feedback_memory.py +53 -0
  20. package/python/biocore_model/needs_events.py +32 -11
  21. package/python/biocore_model/regulation_strategies.py +5 -0
  22. package/python/biocore_model/repair_after_boundary.py +1 -0
  23. package/python/biocore_model/self_esteem.py +14 -2
  24. package/python/biocore_model/self_intent.py +6 -4
  25. package/python/biocore_model/tests/test_autonomy_self.py +153 -153
  26. package/python/biocore_model/tests/test_cache_series.py +137 -0
  27. package/python/biocore_model/tests/test_core_state.py +240 -240
  28. package/python/biocore_model/tests/test_extensions.py +167 -167
  29. package/python/biocore_model/tests/test_field_arbiter.py +444 -0
  30. package/python/biocore_model/tests/test_governance.py +181 -181
  31. package/python/biocore_model/tests/test_lifecycle.py +153 -0
  32. package/python/biocore_model/tests/test_memory_inject.py +211 -211
  33. package/python/biocore_model/tests/test_retrieval_plus.py +143 -0
  34. package/python/biocore_model/tests/test_signals.py +106 -106
  35. package/python/biocore_model/tests/test_standby.py +199 -0
  36. package/python/biocore_runtime/__init__.py +3 -0
  37. package/python/biocore_runtime/arbiter.py +307 -0
  38. package/python/biocore_runtime/batch_b.py +724 -0
  39. package/python/biocore_runtime/batch_c.py +186 -0
  40. package/python/biocore_runtime/batch_d.py +659 -0
  41. package/python/biocore_runtime/batch_e.py +328 -0
  42. package/python/biocore_runtime/batch_registry.py +446 -0
  43. package/python/biocore_runtime/cache_stats.py +161 -0
  44. package/python/biocore_runtime/core_state.py +106 -3
  45. package/python/biocore_runtime/dimensions.py +419 -0
  46. package/python/biocore_runtime/expression.py +261 -0
  47. package/python/biocore_runtime/field.py +440 -0
  48. package/python/biocore_runtime/lifecycle.py +233 -0
  49. package/python/biocore_runtime/perception.py +1045 -0
  50. package/python/biocore_runtime/pipeline.py +4458 -2974
  51. package/python/biocore_runtime/priority.py +210 -0
  52. package/python/biocore_runtime/retrieval_plus.py +352 -0
  53. package/python/biocore_runtime/standby.py +581 -472
  54. package/python/biocore_runtime/timescales.py +263 -0
  55. package/python/data/signals.json +3266 -206
  56. package/ui/panel-layout.js +32 -29
  57. package/ui/panel.css +563 -974
  58. package/ui/panel.html +427 -401
  59. package/ui/panel.js +543 -450
@@ -0,0 +1,444 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-228: 学习耦合仲裁层 -- field 单一真源 + 写回仲裁.
3
+
4
+ Run from the plugin root:
5
+
6
+ python -m unittest discover -s biocore_model/tests -p "test_field_arbiter.py"
7
+ """
8
+ import json
9
+ import math
10
+ import os
11
+ import tempfile
12
+ import unittest
13
+ from pathlib import Path
14
+
15
+ from biocore_memory.engine import MemoryEngine
16
+ from biocore_runtime import arbiter as arb
17
+ from biocore_runtime import field as fld
18
+ from biocore_runtime import pipeline as pl
19
+
20
+
21
+ NOW = 1700000000.0
22
+
23
+
24
+ def write_json(path, data):
25
+ path = Path(path)
26
+ path.parent.mkdir(parents=True, exist_ok=True)
27
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2),
28
+ encoding="utf-8")
29
+
30
+
31
+ def entry(i, text, affect=None, importance=0.5, ts=NOW):
32
+ value = {"id": i, "text": text, "role": "user", "ts": ts,
33
+ "importance": importance, "vector": [], "hits": 1,
34
+ "self_relevance": 0.0, "pin": False, "memory_source": "direct",
35
+ "memory_confidence": 0.7, "previous_versions": [],
36
+ "module": "biocore_memory", "scope": "default"}
37
+ if affect is not None:
38
+ value["affect"] = affect
39
+ return value
40
+
41
+
42
+ class FieldValueTest(unittest.TestCase):
43
+ """Pure field: ranges, defaults, read-only copies, state projection."""
44
+
45
+ def test_defaults_and_range_clip(self):
46
+ value = fld.Field()
47
+ self.assertEqual(value.read(), {"v": 0.0, "a": 0.5, "stress": 0.5,
48
+ "social": 0.5, "trust": 0.5,
49
+ "closeness": 0.5})
50
+ clipped = fld.Field({"v": 9.0, "a": -3.0}, version=2)
51
+ self.assertEqual(clipped.get("v"), 1.0)
52
+ self.assertEqual(clipped.get("a"), 0.0)
53
+ self.assertEqual(clipped.version, 2)
54
+ self.assertFalse(clipped.set("nope", 1.0)) # unknown name is ignored
55
+
56
+ def test_read_returns_a_copy(self):
57
+ value = fld.Field({"v": 0.25})
58
+ snapshot = value.read()
59
+ snapshot["v"] = -1.0
60
+ self.assertEqual(value.get("v"), 0.25) # learners cannot mutate the field
61
+ self.assertEqual(value.read(), {"v": 0.25, "a": 0.5, "stress": 0.5,
62
+ "social": 0.5, "trust": 0.5,
63
+ "closeness": 0.5})
64
+
65
+ def test_from_state_reads_core_and_relationship(self):
66
+ state = {"core": {"v": 0.4, "a": 0.7, "social": 0.2},
67
+ "stress": 0.6,
68
+ "relationship": {"trust": 0.8, "closeness": 0.3}}
69
+ value = fld.from_state(state)
70
+ self.assertAlmostEqual(value.get("v"), 0.4)
71
+ self.assertAlmostEqual(value.get("a"), 0.7)
72
+ self.assertAlmostEqual(value.get("social"), 0.2)
73
+ self.assertAlmostEqual(value.get("stress"), 0.6) # top-level fallback
74
+ self.assertAlmostEqual(value.get("trust"), 0.8)
75
+ self.assertAlmostEqual(value.get("closeness"), 0.3)
76
+
77
+ def test_from_state_accepts_legacy_aliases(self):
78
+ value = fld.from_state({"valence": -0.5, "arousal": 0.9})
79
+ self.assertAlmostEqual(value.get("v"), -0.5)
80
+ self.assertAlmostEqual(value.get("a"), 0.9)
81
+
82
+ def test_to_state_projects_affect_only_without_relationship(self):
83
+ value = fld.Field({"v": 0.3, "a": 0.6, "stress": 0.2, "social": 0.9,
84
+ "trust": 0.7})
85
+ out = fld.to_state({"session_id": "s", "core": {"energy": 0.4}}, value)
86
+ self.assertAlmostEqual(out["core"]["v"], 0.3)
87
+ self.assertAlmostEqual(out["core"]["a"], 0.6)
88
+ self.assertAlmostEqual(out["core"]["stress"], 0.2)
89
+ self.assertAlmostEqual(out["core"]["social"], 0.9)
90
+ self.assertAlmostEqual(out["core"]["energy"], 0.4) # untouched
91
+ self.assertNotIn("relationship", out) # never invents the sidecar key
92
+
93
+ def test_to_state_writes_relationship_when_present(self):
94
+ value = fld.Field({"trust": 0.9, "closeness": 0.1})
95
+ out = fld.to_state({"relationship": {"trust": 0.5, "closeness": 0.5,
96
+ "conflict": 0.2}}, value)
97
+ self.assertAlmostEqual(out["relationship"]["trust"], 0.9)
98
+ self.assertAlmostEqual(out["relationship"]["closeness"], 0.1)
99
+ self.assertAlmostEqual(out["relationship"]["conflict"], 0.2)
100
+
101
+ def test_switch_defaults_on_and_can_be_turned_off(self):
102
+ self.assertTrue(fld.enabled())
103
+ self.assertFalse(fld.enabled(flags={"field_arbiter": False}))
104
+ self.assertFalse(fld.enabled(env={"BIOCORE_FIELD_ARBITER": "0"}))
105
+ self.assertTrue(fld.enabled(env={"BIOCORE_FIELD_ARBITER": "1"}))
106
+ self.assertTrue(fld.enabled(flags={}, env={})) # default ON
107
+
108
+
109
+ class ArbiterTest(unittest.TestCase):
110
+ """Conflict resolution, caps, rejections, idempotency."""
111
+
112
+ def test_weighted_median_picks_the_heavier_side(self):
113
+ self.assertAlmostEqual(
114
+ arb.weighted_median([(0.06, 1.0), (-0.02, 0.6)]), 0.06)
115
+ self.assertAlmostEqual(
116
+ arb.weighted_median([(0.03, 0.5), (-0.01, 3.0)]), -0.01)
117
+ # 0.2 weight ties: the lower delta is reached first -> deterministic
118
+ self.assertAlmostEqual(
119
+ arb.weighted_median([(0.10, 1.0), (0.02, 1.0)]), 0.02)
120
+
121
+ def test_conflict_resolved_by_weighted_median(self):
122
+ value = fld.Field({"v": 0.20})
123
+ a = arb.Arbiter()
124
+ out = a.apply(value, {"memory_feedback": {"v": 0.06},
125
+ "mood_recall": {"v": -0.02}})
126
+ self.assertAlmostEqual(out["applied"]["v"], 0.06)
127
+ self.assertAlmostEqual(out["field"]["v"], 0.26)
128
+ self.assertEqual(len(out["conflicts"]), 1)
129
+ self.assertEqual(out["conflicts"][0]["field"], "v")
130
+ self.assertAlmostEqual(out["conflicts"][0]["winner"], 0.06)
131
+ self.assertEqual(a.stats(), {"applied": 1, "rejected": 0,
132
+ "conflicts": 1})
133
+
134
+ def test_single_apply_cap(self):
135
+ value = fld.Field({"v": 0.20})
136
+ a = arb.Arbiter()
137
+ out = a.apply(value, [{"learner": "memory_feedback", "field": "v",
138
+ "delta": 0.50}])
139
+ self.assertAlmostEqual(out["resolved"]["v"]["capped"], 0.10)
140
+ self.assertAlmostEqual(out["applied"]["v"], 0.10)
141
+ self.assertAlmostEqual(out["field"]["v"], 0.30)
142
+
143
+ def test_trust_uses_the_relationship_cap(self):
144
+ value = fld.Field({"trust": 0.50})
145
+ a = arb.Arbiter()
146
+ out = a.apply(value, {"preference_drift": {"trust": 0.40}})
147
+ self.assertAlmostEqual(out["resolved"]["trust"]["capped"], 0.02)
148
+ self.assertAlmostEqual(out["field"]["trust"], 0.52)
149
+
150
+ def test_bad_proposals_are_rejected_not_applied(self):
151
+ value = fld.Field({"v": 0.10})
152
+ a = arb.Arbiter()
153
+ out = a.apply(value, [{"learner": "x", "field": "nope", "delta": 0.1},
154
+ {"learner": "x", "field": "v", "delta": "abc"},
155
+ {"learner": "x", "field": "v", "delta": 0.0}])
156
+ reasons = sorted(item["reason"] for item in out["rejected"])
157
+ self.assertEqual(reasons, ["bad_delta", "unknown_field", "zero_delta"])
158
+ self.assertEqual(out["applied"], {})
159
+ self.assertAlmostEqual(out["field"]["v"], 0.10)
160
+ self.assertEqual(a.stats()["rejected"], 3)
161
+
162
+ def test_range_clip_is_reported_then_no_effect(self):
163
+ value = fld.Field({"v": 0.98})
164
+ a = arb.Arbiter()
165
+ first = a.apply(value, {"memory_feedback": {"v": 0.10}})
166
+ self.assertAlmostEqual(first["resolved"]["v"]["capped"], 0.10)
167
+ self.assertAlmostEqual(first["field"]["v"], 1.0)
168
+ self.assertAlmostEqual(first["applied"]["v"], 0.02) # only 0.02 was usable
169
+ second = a.apply(value, {"memory_feedback": {"v": 0.10}})
170
+ self.assertEqual(second["applied"], {})
171
+ self.assertEqual(second["rejected"][0]["reason"], "no_effect")
172
+ self.assertAlmostEqual(second["field"]["v"], 1.0)
173
+
174
+ def test_txid_makes_apply_idempotent(self):
175
+ value = fld.Field({"v": 0.20})
176
+ a = arb.Arbiter()
177
+ first = a.apply(value, {"memory_feedback": {"v": 0.05}}, txid="turn-7")
178
+ self.assertFalse(first["duplicate"])
179
+ self.assertAlmostEqual(first["field"]["v"], 0.25)
180
+ before = value.read()
181
+ second = a.apply(value, {"memory_feedback": {"v": 0.05}}, txid="turn-7")
182
+ self.assertTrue(second["duplicate"])
183
+ self.assertEqual(second["applied"], {})
184
+ self.assertEqual(value.read(), before)
185
+ self.assertEqual(a.snapshot()["duplicates"], 1)
186
+ # the ledger survives a round trip through field.json
187
+ again = arb.Arbiter(state=a.snapshot())
188
+ third = again.apply(value, {"memory_feedback": {"v": 0.05}},
189
+ txid="turn-7")
190
+ self.assertTrue(third["duplicate"])
191
+
192
+ def test_disabled_apply_is_a_noop(self):
193
+ value = fld.Field({"v": 0.20})
194
+ a = arb.Arbiter()
195
+ out = a.apply(value, {"memory_feedback": {"v": 0.05}}, enabled=False)
196
+ self.assertFalse(out["enabled"])
197
+ self.assertEqual(out["applied"], {})
198
+ self.assertAlmostEqual(value.get("v"), 0.20)
199
+ self.assertEqual(a.stats()["applied"], 0)
200
+
201
+
202
+ class FieldStoreTest(unittest.TestCase):
203
+ """<store>/sessions/<sid>/field.json persistence."""
204
+
205
+ def setUp(self):
206
+ self.tmp = tempfile.TemporaryDirectory()
207
+ self.store = Path(self.tmp.name) / "store"
208
+
209
+ def tearDown(self):
210
+ self.tmp.cleanup()
211
+
212
+ def test_roundtrip_is_per_session(self):
213
+ one = fld.Field({"v": 0.30}, version=3, session_id="s1")
214
+ two = fld.Field({"v": -0.40}, version=1, session_id="s2")
215
+ self.assertTrue(fld.save(self.store, fld.doc(one, {}, now=NOW), "s1"))
216
+ self.assertTrue(fld.save(self.store, fld.doc(two, {}, now=NOW), "s2"))
217
+ self.assertTrue((self.store / "sessions" / "s1" / "field.json").exists())
218
+ self.assertAlmostEqual(fld.from_doc(fld.load(self.store, "s1")).get("v"), 0.30)
219
+ self.assertAlmostEqual(fld.from_doc(fld.load(self.store, "s2")).get("v"), -0.40)
220
+ self.assertEqual(fld.load(self.store, "s3"), {}) # never invented
221
+
222
+ def test_reconcile_keeps_field_and_adopts_external_writes(self):
223
+ stored = fld.Field({"v": 0.30}, version=4, session_id="s1")
224
+ same = fld.reconcile(stored, fld.from_state({"core": {"v": 0.30}}))
225
+ self.assertFalse(same[1])
226
+ self.assertEqual(same[0].version, 4)
227
+ self.assertEqual(same[0].source, "field")
228
+ moved = fld.reconcile(stored, fld.from_state({"core": {"v": 0.50}}))
229
+ self.assertTrue(moved[1])
230
+ self.assertAlmostEqual(moved[0].get("v"), 0.50)
231
+ self.assertEqual(moved[0].version, 5)
232
+ self.assertEqual(moved[0].source, "state")
233
+
234
+
235
+ class FieldPipelineTest(unittest.TestCase):
236
+ """turn_start / turn_end / state wiring."""
237
+
238
+ def setUp(self):
239
+ self.tmp = tempfile.TemporaryDirectory()
240
+ self.store = Path(self.tmp.name) / "store"
241
+ self._saved = os.environ.pop("BIOCORE_FIELD_ARBITER", None)
242
+ # hermetic persona lookup: no ~/.dsh/biocore/persona_profile.json
243
+ self._home = os.environ.get("BIOCORE_HOME")
244
+ os.environ["BIOCORE_HOME"] = str(Path(self.tmp.name) / "home")
245
+
246
+ def tearDown(self):
247
+ self.tmp.cleanup()
248
+ if self._saved is None:
249
+ os.environ.pop("BIOCORE_FIELD_ARBITER", None)
250
+ else:
251
+ os.environ["BIOCORE_FIELD_ARBITER"] = self._saved
252
+ if self._home is None:
253
+ os.environ.pop("BIOCORE_HOME", None)
254
+ else:
255
+ os.environ["BIOCORE_HOME"] = self._home
256
+
257
+ def start(self, session="s1", v=0.20, a=0.60, trust=0.50, **extra):
258
+ payload = {"session_id": session, "store": str(self.store),
259
+ "user_text": "今天有点累",
260
+ "state": {"core": {"v": v, "a": a, "stress": 0.40,
261
+ "social": 0.50},
262
+ "relationship": {"trust": trust, "closeness": 0.50}}}
263
+ payload.update(extra)
264
+ return pl.dispatch("turn_start", payload)
265
+
266
+ def end(self, session="s1", deltas=None, **extra):
267
+ payload = {"session_id": session, "store": str(self.store),
268
+ "assistant_text": "那就先歇一会儿吧。"}
269
+ if deltas is not None:
270
+ payload["learner_deltas"] = deltas
271
+ payload.update(extra)
272
+ return pl.dispatch("turn_end", payload)
273
+
274
+ def test_turn_start_seeds_and_persists_the_field(self):
275
+ out = self.start()
276
+ self.assertTrue(out["field"]["enabled"])
277
+ self.assertTrue(out["field"]["reconciled"])
278
+ self.assertEqual(out["field"]["source"], "state")
279
+ self.assertAlmostEqual(out["field"]["field"]["v"], 0.20)
280
+ self.assertAlmostEqual(out["field"]["field"]["a"], 0.60)
281
+ self.assertAlmostEqual(out["field"]["field"]["trust"], 0.50)
282
+ self.assertTrue((self.store / "sessions" / "s1" / "field.json").exists())
283
+
284
+ def test_turn_start_reuses_the_field_until_the_state_drifts(self):
285
+ self.start()
286
+ again = self.start()
287
+ self.assertFalse(again["field"]["reconciled"])
288
+ self.assertEqual(again["field"]["source"], "field")
289
+ state_file = self.store / "sessions" / "s1" / "state.json"
290
+ state = json.loads(state_file.read_text(encoding="utf-8"))
291
+ state["core"]["v"] = 0.55 # host wrote the affect outside the arbiter
292
+ write_json(state_file, state)
293
+ # no state payload: turn_start reads what the host persisted
294
+ adopted = pl.dispatch("turn_start", {
295
+ "session_id": "s1", "store": str(self.store),
296
+ "user_text": "今天有点累"})
297
+ self.assertTrue(adopted["field"]["reconciled"])
298
+ self.assertAlmostEqual(adopted["field"]["field"]["v"], 0.55)
299
+ self.assertEqual(adopted["field"]["version"], 1)
300
+
301
+ def test_turn_end_arbitrates_and_writes_state_and_state_action(self):
302
+ self.start()
303
+ out = self.end(deltas={"memory_feedback": {"v": 0.06, "a": -0.02},
304
+ "mood_recall": {"v": -0.02},
305
+ "preference_drift": {"trust": 0.05}},
306
+ txid="turn-1")
307
+ self.assertAlmostEqual(out["field"]["field"]["v"], 0.26)
308
+ self.assertAlmostEqual(out["field"]["field"]["a"], 0.58)
309
+ self.assertAlmostEqual(out["field"]["field"]["trust"], 0.52) # capped
310
+ self.assertEqual(out["field"]["conflicts"][0]["field"], "v")
311
+ state = json.loads((self.store / "sessions" / "s1" / "state.json")
312
+ .read_text(encoding="utf-8"))
313
+ self.assertAlmostEqual(state["core"]["v"], 0.26)
314
+ self.assertAlmostEqual(state["core"]["a"], 0.58)
315
+ shown = pl.dispatch("state", {"session_id": "s1",
316
+ "store": str(self.store)})
317
+ self.assertAlmostEqual(shown["state"]["field"]["v"], 0.26)
318
+ self.assertEqual(shown["state"]["arbiter"]["applied"], 3)
319
+ self.assertEqual(shown["state"]["arbiter"]["conflicts"], 1)
320
+ self.assertEqual(shown["state"]["arbiter"]["rejected"], 0)
321
+
322
+ def test_turn_end_retry_with_same_txid_is_idempotent(self):
323
+ self.start()
324
+ first = self.end(deltas={"memory_feedback": {"v": 0.06}}, txid="turn-1")
325
+ self.assertAlmostEqual(first["field"]["field"]["v"], 0.26)
326
+ second = self.end(deltas={"memory_feedback": {"v": 0.06}}, txid="turn-1")
327
+ self.assertTrue(second["field"]["duplicate"])
328
+ self.assertAlmostEqual(second["field"]["field"]["v"], 0.26)
329
+ self.assertEqual(second["field"]["applied"], {})
330
+
331
+ def test_host_state_push_is_capped_like_any_other_write(self):
332
+ self.start(v=0.20)
333
+ out = self.end(state={"core": {"v": 0.90}})
334
+ self.assertAlmostEqual(out["field"]["applied"]["v"], 0.10)
335
+ self.assertAlmostEqual(out["field"]["field"]["v"], 0.30)
336
+
337
+ def test_switch_off_restores_old_behaviour(self):
338
+ out = self.start(field_arbiter=False)
339
+ self.assertFalse(out["field"]["enabled"])
340
+ self.assertFalse((self.store / "sessions" / "s1" / "field.json").exists())
341
+ ended = self.end(deltas={"memory_feedback": {"v": 0.06}},
342
+ field_arbiter=False)
343
+ self.assertFalse(ended["field"]["enabled"])
344
+ self.assertEqual(ended["field"]["applied"], {})
345
+ shown = pl.dispatch("state", {"session_id": "s1",
346
+ "store": str(self.store),
347
+ "field_arbiter": False})
348
+ self.assertNotIn("field", shown["state"])
349
+ self.assertNotIn("arbiter", shown["state"])
350
+ # the same read with the switch on still works (default ON)
351
+ on = pl.dispatch("state", {"session_id": "s1", "store": str(self.store)})
352
+ self.assertIn("field", on["state"])
353
+
354
+ def test_switch_off_via_flags_file(self):
355
+ write_json(self.store / "flags.json", {"field_arbiter": False})
356
+ out = self.start()
357
+ self.assertFalse(out["field"]["enabled"])
358
+
359
+
360
+ class EngineFieldBindingTest(unittest.TestCase):
361
+ """Learners read the field and propose deltas instead of writing."""
362
+
363
+ def setUp(self):
364
+ self.tmp = tempfile.TemporaryDirectory()
365
+ self.dir = Path(self.tmp.name)
366
+ self.clock = [NOW]
367
+ self._env = {key: os.environ.pop(key, None) for key in (
368
+ "BIOCORE_MEMORY_FEEDBACK", "BIOCORE_MOOD_RECALL",
369
+ "BIOCORE_AFFECT_ENCODING")}
370
+
371
+ def tearDown(self):
372
+ self.tmp.cleanup()
373
+ for key, value in self._env.items():
374
+ if value is None:
375
+ os.environ.pop(key, None)
376
+ else:
377
+ os.environ[key] = value
378
+
379
+ def write(self, entries):
380
+ (self.dir / "memory.json").write_text(
381
+ json.dumps({"entries": entries, "tombstones": [],
382
+ "schema_version": 1}), encoding="utf-8")
383
+
384
+ def engine(self, affect):
385
+ return MemoryEngine(data_dir=str(self.dir), affect=affect,
386
+ now=lambda: self.clock[0])
387
+
388
+ def test_mood_recall_reads_the_field_not_the_cached_affect(self):
389
+ self.write([entry("warm", "beach warm",
390
+ {"valence": 0.5, "arousal": 0.5})])
391
+ affect = {"v": -1.0, "a": 0.5} # stale private copy
392
+ e = self.engine(affect)
393
+ e.flags["mood_recall"] = True
394
+ e.bind_field({"v": 0.5, "a": 0.5}) # the field says otherwise
395
+ rows = e.recall("beach", budget=3)
396
+ self.assertEqual(len(rows), 1)
397
+ self.assertAlmostEqual(rows[0]["mood_cos"], 1.0, places=6)
398
+
399
+ def test_memory_feedback_proposes_instead_of_writing(self):
400
+ self.write([entry("warm", "beach warm",
401
+ {"valence": 0.8, "arousal": 0.9})])
402
+ affect = {"v": 0.0, "a": 0.5}
403
+ e = self.engine(affect)
404
+ e.flags["memory_feedback"] = True
405
+ e.bind_field({"v": 0.0, "a": 0.5})
406
+ e.recall("beach", budget=3)
407
+ report = e._last_memory_feedback
408
+ self.assertTrue(report["deferred"])
409
+ self.assertEqual(report["field_write"], "proposal")
410
+ self.assertAlmostEqual(affect["v"], 0.0, places=6) # nothing written
411
+ proposals = e.drain_field_proposals()
412
+ self.assertEqual([(p["learner"], p["field"]) for p in proposals],
413
+ [("memory_feedback", "v"), ("memory_feedback", "a")])
414
+ self.assertAlmostEqual(proposals[0]["delta"], 0.02, places=6)
415
+ self.assertAlmostEqual(proposals[1]["delta"], 0.02, places=6)
416
+ # and the proposal is exactly what the arbiter then applies
417
+ value = fld.Field({"v": 0.0, "a": 0.5})
418
+ out = arb.Arbiter().apply(value, proposals)
419
+ self.assertAlmostEqual(out["field"]["v"], 0.02)
420
+ self.assertAlmostEqual(out["field"]["a"], 0.52)
421
+
422
+ def test_unbound_engine_keeps_the_old_direct_write(self):
423
+ self.write([entry("warm", "beach warm",
424
+ {"valence": 0.8, "arousal": 0.9})])
425
+ affect = {"v": 0.0, "a": 0.5}
426
+ e = self.engine(affect)
427
+ e.flags["memory_feedback"] = True
428
+ e.recall("beach", budget=3)
429
+ self.assertAlmostEqual(affect["v"], 0.02, places=4)
430
+ self.assertEqual(e.drain_field_proposals(), [])
431
+ self.assertIsNone(e.field_view())
432
+
433
+ def test_bind_field_copies_and_can_be_released(self):
434
+ e = self.engine(None)
435
+ view = {"v": 0.3, "a": 0.6}
436
+ bound = e.bind_field(view)
437
+ view["v"] = -1.0
438
+ self.assertAlmostEqual(bound["v"], 0.3) # a copy, not a reference
439
+ e.bind_field(None)
440
+ self.assertIsNone(e.field_view())
441
+
442
+
443
+ if __name__ == "__main__":
444
+ unittest.main()