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.
- package/lib/index.js +30 -1
- package/lib/modules/standby.js +61 -0
- package/lib/modules/state.js +22 -3
- package/package.json +1 -1
- package/python/biocore_memory/core/config.py +7 -3
- package/python/biocore_memory/engine.py +91 -6
- package/python/biocore_memory/recall/embedding.py +0 -51
- package/python/biocore_memory/recall/feedback.py +34 -7
- package/python/biocore_memory/recall/fusion.py +5 -1
- package/python/biocore_memory/scoring.py +203 -0
- package/python/biocore_memory/space.py +355 -0
- package/python/biocore_memory/store/dedupe.py +1 -0
- package/python/biocore_memory/store/tiering.py +14 -3
- package/python/biocore_model/affective_labor.py +14 -1
- package/python/biocore_model/appraisal.py +172 -3
- package/python/biocore_model/bio_tick.py +43 -13
- package/python/biocore_model/emotion_geometry.py +9 -0
- package/python/biocore_model/envy.py +21 -0
- package/python/biocore_model/feedback_memory.py +53 -0
- package/python/biocore_model/needs_events.py +32 -11
- package/python/biocore_model/regulation_strategies.py +5 -0
- package/python/biocore_model/repair_after_boundary.py +1 -0
- package/python/biocore_model/self_esteem.py +14 -2
- package/python/biocore_model/self_intent.py +6 -4
- package/python/biocore_model/tests/test_autonomy_self.py +153 -153
- package/python/biocore_model/tests/test_cache_series.py +137 -0
- package/python/biocore_model/tests/test_core_state.py +240 -240
- package/python/biocore_model/tests/test_extensions.py +167 -167
- package/python/biocore_model/tests/test_field_arbiter.py +444 -0
- package/python/biocore_model/tests/test_governance.py +181 -181
- package/python/biocore_model/tests/test_lifecycle.py +153 -0
- package/python/biocore_model/tests/test_memory_inject.py +211 -211
- package/python/biocore_model/tests/test_retrieval_plus.py +143 -0
- package/python/biocore_model/tests/test_signals.py +106 -106
- package/python/biocore_model/tests/test_standby.py +199 -0
- package/python/biocore_runtime/__init__.py +3 -0
- package/python/biocore_runtime/arbiter.py +307 -0
- package/python/biocore_runtime/batch_b.py +724 -0
- package/python/biocore_runtime/batch_c.py +186 -0
- package/python/biocore_runtime/batch_d.py +659 -0
- package/python/biocore_runtime/batch_e.py +328 -0
- package/python/biocore_runtime/batch_registry.py +446 -0
- package/python/biocore_runtime/cache_stats.py +161 -0
- package/python/biocore_runtime/core_state.py +106 -3
- package/python/biocore_runtime/dimensions.py +419 -0
- package/python/biocore_runtime/expression.py +261 -0
- package/python/biocore_runtime/field.py +440 -0
- package/python/biocore_runtime/lifecycle.py +233 -0
- package/python/biocore_runtime/perception.py +1045 -0
- package/python/biocore_runtime/pipeline.py +4458 -2974
- package/python/biocore_runtime/priority.py +210 -0
- package/python/biocore_runtime/retrieval_plus.py +352 -0
- package/python/biocore_runtime/standby.py +581 -472
- package/python/biocore_runtime/timescales.py +263 -0
- package/python/data/signals.json +3266 -206
- package/ui/panel-layout.js +32 -29
- package/ui/panel.css +563 -974
- package/ui/panel.html +427 -401
- 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()
|