dsh-biocore 0.4.0 → 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/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_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 -137
- 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 -153
- package/python/biocore_model/tests/test_memory_inject.py +211 -211
- package/python/biocore_model/tests/test_retrieval_plus.py +143 -143
- package/python/biocore_model/tests/test_runtime_pipeline.py +1 -1
- package/python/biocore_model/tests/test_signals.py +106 -106
- package/python/biocore_model/tests/test_standby.py +199 -199
- 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/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/perception.py +1045 -0
- package/python/biocore_runtime/pipeline.py +4458 -3105
- package/python/biocore_runtime/priority.py +210 -0
- package/python/biocore_runtime/standby.py +581 -579
- package/python/biocore_runtime/timescales.py +263 -0
- 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
package/lib/modules/state.js
CHANGED
|
@@ -8,7 +8,13 @@ import { readFileSync, statSync } from 'node:fs'
|
|
|
8
8
|
import { join } from 'node:path'
|
|
9
9
|
import { resolveDataDir } from './data-dir.js'
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** /state is forwarded whole. The core state now carries field, batches,
|
|
12
|
+
* dimensions, perception and friends, so ~50KB is a normal size; the old 16KB
|
|
13
|
+
* budget only made every session look broken. Keep a hard ceiling so a runaway
|
|
14
|
+
* payload still degrades instead of flooding the host. */
|
|
15
|
+
export const STATE_LIMIT = 512 * 1024
|
|
16
|
+
/** Old budget: log once past it so slow growth stays visible. */
|
|
17
|
+
export const STATE_WARN = 16 * 1024
|
|
12
18
|
export const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/
|
|
13
19
|
|
|
14
20
|
const TONE_LABELS = {
|
|
@@ -170,11 +176,24 @@ export function readStateFile(file) {
|
|
|
170
176
|
return { state: raw, mtimeMs }
|
|
171
177
|
}
|
|
172
178
|
|
|
173
|
-
|
|
179
|
+
let lastBytesLogAt = 0
|
|
180
|
+
|
|
181
|
+
/** True when the serialized payload fits the 512KB /state budget. */
|
|
174
182
|
export function payloadWithinLimit(body) {
|
|
183
|
+
let bytes = 0
|
|
175
184
|
try {
|
|
176
|
-
|
|
185
|
+
bytes = Buffer.byteLength(JSON.stringify(body), 'utf8')
|
|
177
186
|
} catch {
|
|
178
187
|
return false
|
|
179
188
|
}
|
|
189
|
+
if (bytes > STATE_WARN) {
|
|
190
|
+
const now = Date.now()
|
|
191
|
+
if (now - lastBytesLogAt >= 60 * 1000) {
|
|
192
|
+
lastBytesLogAt = now
|
|
193
|
+
try {
|
|
194
|
+
console.error('__bioStateBytes ' + bytes + ' limit=' + STATE_LIMIT)
|
|
195
|
+
} catch { /* logging must never fail the request */ }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return bytes <= STATE_LIMIT
|
|
180
199
|
}
|
package/package.json
CHANGED
|
@@ -8,7 +8,9 @@ TOMBSTONE_LIMIT = 100
|
|
|
8
8
|
DEFAULT_BUDGET = 3
|
|
9
9
|
MAX_BUDGET = 5
|
|
10
10
|
|
|
11
|
-
# recall fusion / tiers (M2)
|
|
11
|
+
# recall fusion / tiers (M2). [engineering] no fitting source; .50/.30/.10/.10
|
|
12
|
+
# is the single口径 now (the old "0.6/0.4" comment and the task's .4/.3/.2/.1
|
|
13
|
+
# are retired, code unchanged).
|
|
12
14
|
CUE_WEIGHT = 0.50
|
|
13
15
|
SEM_WEIGHT = 0.30
|
|
14
16
|
IMPORTANCE_WEIGHT = 0.10
|
|
@@ -21,6 +23,7 @@ SOURCE_MIN_CONFIDENCE = 0.55
|
|
|
21
23
|
SOURCE_CONFIDENCE_DEFAULT = 0.70
|
|
22
24
|
|
|
23
25
|
# memory value formula (deterministic; trust is NOT part of value)
|
|
26
|
+
# [engineering] product definition, no behavioural data behind the weights.
|
|
24
27
|
VALUE_BASE = 0.20
|
|
25
28
|
VALUE_W_INTENSITY = 0.30
|
|
26
29
|
VALUE_W_VALENCE = 0.20
|
|
@@ -28,7 +31,7 @@ VALUE_W_SELF = 0.15
|
|
|
28
31
|
VALUE_W_MAINLINE = 0.15
|
|
29
32
|
VALUE_W_ROUTINE = 0.20
|
|
30
33
|
|
|
31
|
-
# eviction weights (read-only score)
|
|
34
|
+
# eviction weights (read-only score) [engineering]
|
|
32
35
|
EVICT_W_SALIENCE = 0.30
|
|
33
36
|
EVICT_W_POSITION = 0.20
|
|
34
37
|
EVICT_W_REDUNDANCY = 0.20
|
|
@@ -46,7 +49,7 @@ THREAD_LIMIT = 3
|
|
|
46
49
|
|
|
47
50
|
# memory-point gate (OR): value >= MIN_VALUE or importance >= MIN_IMPORTANCE or self >= MIN_SELF
|
|
48
51
|
# (event centrality / emotional intensity; self-reference effect; engineering thresholds)
|
|
49
|
-
MEMORY_POINT_MIN_VALUE = 0.60
|
|
52
|
+
MEMORY_POINT_MIN_VALUE = 0.60 # [engineering]
|
|
50
53
|
MEMORY_POINT_MIN_SELF = 0.45
|
|
51
54
|
MEMORY_POINT_MIN_IMPORTANCE = 0.75
|
|
52
55
|
|
|
@@ -60,6 +63,7 @@ CONSOLIDATE_AUTO_TURNS = 10
|
|
|
60
63
|
|
|
61
64
|
# recall -> affect feedback (task-218): a recalled memory nudges the live
|
|
62
65
|
# affect a little. Engine owns the state; recall/feedback.py owns the guards.
|
|
66
|
+
# [engineering] gain / step / cooldown / floor are hand-set (see PAPER-MAP.md).
|
|
63
67
|
FEEDBACK_MAX_STEP = 0.02 # per-turn cap on |delta valence| and |delta arousal|
|
|
64
68
|
FEEDBACK_GAIN = 0.50 # share of (recalled - current) that flows back
|
|
65
69
|
FEEDBACK_COOLDOWN = 1800.0 # same entry: no second feedback inside 30 minutes
|
|
@@ -98,6 +98,11 @@ class MemoryEngine:
|
|
|
98
98
|
eviction_readonly=None, identity_writeback=None,
|
|
99
99
|
config=None, slots_enabled=None, due_hints_enabled=None):
|
|
100
100
|
self.affect = affect
|
|
101
|
+
# task-228: the shared field (single source of truth). Unbound by
|
|
102
|
+
# default -- every existing caller keeps reading/writing self.affect.
|
|
103
|
+
self._field_view = None
|
|
104
|
+
self._field_arbiter = False
|
|
105
|
+
self._field_proposals = []
|
|
101
106
|
self.config = config if isinstance(config, dict) else {}
|
|
102
107
|
self.flags = dict(FLAGS)
|
|
103
108
|
overrides = (self.config.get("flags")
|
|
@@ -258,6 +263,65 @@ class MemoryEngine:
|
|
|
258
263
|
elif not self.slots_enabled:
|
|
259
264
|
self.slot_registry = None
|
|
260
265
|
|
|
266
|
+
# -- shared field (task-228) -----------------------------------------
|
|
267
|
+
def bind_field(self, view=None, arbitrate=True):
|
|
268
|
+
"""Bind the shared field (a read-only copy) and its write-back arbiter.
|
|
269
|
+
|
|
270
|
+
With a field bound the learners stop reading their own affect copy:
|
|
271
|
+
mood_recall, affect_encoding and memory_feedback all read the field, and
|
|
272
|
+
memory_feedback stops writing -- it proposes a delta instead, which
|
|
273
|
+
drain_field_proposals() hands to biocore_runtime.arbiter at turn_end.
|
|
274
|
+
bind_field(None) restores the old behaviour exactly.
|
|
275
|
+
"""
|
|
276
|
+
if isinstance(view, dict):
|
|
277
|
+
self._field_view = {key: value for key, value in view.items()
|
|
278
|
+
if not isinstance(value, (dict, list))}
|
|
279
|
+
else:
|
|
280
|
+
self._field_view = None
|
|
281
|
+
self._field_arbiter = bool(arbitrate and self._field_view is not None)
|
|
282
|
+
self._field_proposals = []
|
|
283
|
+
return self.field_view()
|
|
284
|
+
|
|
285
|
+
def field_view(self):
|
|
286
|
+
"""Copy of the bound field, or None when no field is bound."""
|
|
287
|
+
return dict(self._field_view) if isinstance(self._field_view, dict) else None
|
|
288
|
+
|
|
289
|
+
def _affect_source(self):
|
|
290
|
+
"""What the learners read: the field when bound, else self.affect."""
|
|
291
|
+
view = getattr(self, "_field_view", None)
|
|
292
|
+
if isinstance(view, dict):
|
|
293
|
+
valence = view.get("v", view.get("valence"))
|
|
294
|
+
arousal = view.get("a", view.get("arousal"))
|
|
295
|
+
if valence is not None:
|
|
296
|
+
return {"v": valence,
|
|
297
|
+
"a": 0.5 if arousal is None else arousal}
|
|
298
|
+
return getattr(self, "affect", None)
|
|
299
|
+
|
|
300
|
+
def propose_field(self, learner, values, weight=None):
|
|
301
|
+
"""Record one learner delta; returns True when anything was proposed."""
|
|
302
|
+
data = values if isinstance(values, dict) else {}
|
|
303
|
+
added = False
|
|
304
|
+
for name, delta in data.items():
|
|
305
|
+
try:
|
|
306
|
+
number = float(delta)
|
|
307
|
+
except Exception:
|
|
308
|
+
continue
|
|
309
|
+
if not (number == number) or abs(number) < 1e-6:
|
|
310
|
+
continue
|
|
311
|
+
item = {"learner": str(learner or "unknown")[:32],
|
|
312
|
+
"field": str(name), "delta": round(number, 6)}
|
|
313
|
+
if weight is not None:
|
|
314
|
+
item["weight"] = weight
|
|
315
|
+
self._field_proposals.append(item)
|
|
316
|
+
added = True
|
|
317
|
+
return added
|
|
318
|
+
|
|
319
|
+
def drain_field_proposals(self):
|
|
320
|
+
"""Pop the proposals recorded since the last drain (turn_end input)."""
|
|
321
|
+
items = list(getattr(self, "_field_proposals", []) or [])
|
|
322
|
+
self._field_proposals = []
|
|
323
|
+
return items
|
|
324
|
+
|
|
261
325
|
def _auto_turn_limit(self):
|
|
262
326
|
"""Resolve the auto-consolidate interval (default 10, flags/config)."""
|
|
263
327
|
for raw in (self.flags.get("consolidate_auto_turns"),
|
|
@@ -1023,7 +1087,7 @@ class MemoryEngine:
|
|
|
1023
1087
|
_base_importance = explicit_importance if explicit_importance is not None else value_of(ev)
|
|
1024
1088
|
_encode_on = self._flag("affect_encoding") or str(os.environ.get("BIOCORE_AFFECT_ENCODING") or "").strip().lower() in ("1", "true", "on", "yes")
|
|
1025
1089
|
if _encode_on and explicit_importance is None:
|
|
1026
|
-
_affect_source =
|
|
1090
|
+
_affect_source = self._affect_source()
|
|
1027
1091
|
if _affect_source is not None:
|
|
1028
1092
|
_ev, _ea = _affect_pair(_affect_source)
|
|
1029
1093
|
try:
|
|
@@ -1059,7 +1123,8 @@ class MemoryEngine:
|
|
|
1059
1123
|
"scope": entry_scope,
|
|
1060
1124
|
}
|
|
1061
1125
|
entry.update({k: v for k, v in meta.items() if k not in entry})
|
|
1062
|
-
_affect_source = meta.get("affect") if isinstance(meta.get("affect"), dict)
|
|
1126
|
+
_affect_source = (meta.get("affect") if isinstance(meta.get("affect"), dict)
|
|
1127
|
+
else self._affect_source())
|
|
1063
1128
|
_mv, _ma = _affect_pair(_affect_source)
|
|
1064
1129
|
entry["affect"] = {"valence": round(_mv, 4), "arousal": round(_ma, 4)}
|
|
1065
1130
|
try:
|
|
@@ -1210,7 +1275,7 @@ class MemoryEngine:
|
|
|
1210
1275
|
_lam = float(os.environ.get("BIOCORE_MOOD_LAMBDA") or 0.25)
|
|
1211
1276
|
except Exception:
|
|
1212
1277
|
_lam = 0.25
|
|
1213
|
-
_current = _affect_pair(
|
|
1278
|
+
_current = _affect_pair(self._affect_source())
|
|
1214
1279
|
for row in rows:
|
|
1215
1280
|
entry = row.get("entry") or {}
|
|
1216
1281
|
stored = entry.get("affect") if isinstance(entry.get("affect"), dict) else None
|
|
@@ -1293,7 +1358,22 @@ class MemoryEngine:
|
|
|
1293
1358
|
return False
|
|
1294
1359
|
|
|
1295
1360
|
def _write_affect(self, valence, arousal):
|
|
1296
|
-
"""Write (v, a) back
|
|
1361
|
+
"""Write (v, a) back, or propose it when the shared field is bound.
|
|
1362
|
+
|
|
1363
|
+
task-228: a learner never writes the field. With a field bound the
|
|
1364
|
+
change is recorded as a "memory_feedback" proposal and the arbiter
|
|
1365
|
+
decides at turn_end; without one this is the old direct write.
|
|
1366
|
+
"""
|
|
1367
|
+
if isinstance(getattr(self, "_field_view", None), dict) and self._field_arbiter:
|
|
1368
|
+
try:
|
|
1369
|
+
base_v, base_a = _affect_pair(self._affect_source())
|
|
1370
|
+
value_v = max(-1.0, min(1.0, float(valence)))
|
|
1371
|
+
value_a = max(0.0, min(1.0, float(arousal)))
|
|
1372
|
+
except Exception:
|
|
1373
|
+
return False
|
|
1374
|
+
return bool(self.propose_field("memory_feedback", {
|
|
1375
|
+
"v": round(value_v - base_v, 6),
|
|
1376
|
+
"a": round(value_a - base_a, 6)}))
|
|
1297
1377
|
target = getattr(self, "affect", None)
|
|
1298
1378
|
if target is None:
|
|
1299
1379
|
return False
|
|
@@ -1357,7 +1437,8 @@ class MemoryEngine:
|
|
|
1357
1437
|
if not self._memory_feedback_on():
|
|
1358
1438
|
self._last_memory_feedback = None
|
|
1359
1439
|
return None
|
|
1360
|
-
|
|
1440
|
+
_source = self._affect_source()
|
|
1441
|
+
if _source is None:
|
|
1361
1442
|
self._last_memory_feedback = {"enabled": True, "applied": [],
|
|
1362
1443
|
"delta": [0.0, 0.0],
|
|
1363
1444
|
"reason": "no_affect_state"}
|
|
@@ -1366,7 +1447,7 @@ class MemoryEngine:
|
|
|
1366
1447
|
from .recall import feedback as _feedback_mod
|
|
1367
1448
|
limit = max(1, int(budget))
|
|
1368
1449
|
report = _feedback_mod.plan(
|
|
1369
|
-
list(rows or [])[:limit],
|
|
1450
|
+
list(rows or [])[:limit], _source, self._feedback_last,
|
|
1370
1451
|
now=float(self.now()))
|
|
1371
1452
|
except Exception:
|
|
1372
1453
|
self._last_memory_feedback = None
|
|
@@ -1384,6 +1465,10 @@ class MemoryEngine:
|
|
|
1384
1465
|
report["delta"] = [0.0, 0.0]
|
|
1385
1466
|
report["after"] = list(report.get("before") or [0.0, 0.5])
|
|
1386
1467
|
report["reason"] = "affect_not_writable"
|
|
1468
|
+
if report.get("applied") and self._field_arbiter:
|
|
1469
|
+
# task-228: recorded as a proposal; the arbiter writes at turn_end.
|
|
1470
|
+
report["deferred"] = True
|
|
1471
|
+
report["field_write"] = "proposal"
|
|
1387
1472
|
self._last_memory_feedback = report
|
|
1388
1473
|
return report
|
|
1389
1474
|
|
|
@@ -117,50 +117,6 @@ def _validate_vector(value) -> Optional[List[float]]:
|
|
|
117
117
|
|
|
118
118
|
|
|
119
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
120
|
|
|
165
121
|
|
|
166
122
|
def _cache_file():
|
|
@@ -828,13 +784,6 @@ def _call_provider(provider, text, timeout):
|
|
|
828
784
|
|
|
829
785
|
def embed(text, provider, cache=None, timeout=None):
|
|
830
786
|
"""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):
|
|
838
787
|
if provider is None:
|
|
839
788
|
return None
|
|
840
789
|
normalized = normalize_text(text)
|
|
@@ -94,9 +94,14 @@ def _weight(entry):
|
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
def plan(rows, current, cooldowns=None, now=0.0, budget=None,
|
|
97
|
-
gain=None, max_step=None, cooldown=None, neg_floor=None):
|
|
97
|
+
gain=None, max_step=None, cooldown=None, neg_floor=None, split=True):
|
|
98
98
|
"""Decide the per-turn affect nudge for one recall batch.
|
|
99
99
|
|
|
100
|
+
split=True (default): every recalled memory keeps its own proposal row
|
|
101
|
+
(source id + v/a + weight); no averaged target is invented. The turn then
|
|
102
|
+
takes the most trusted proposal (ties: the larger pull) instead of a mean.
|
|
103
|
+
split=False restores the old weighted-average target exactly.
|
|
104
|
+
|
|
100
105
|
rows what this recall injected (row dicts or raw entries); trimmed
|
|
101
106
|
to budget when given
|
|
102
107
|
current live affect: dict {v/a | valence/arousal}, (v, a) or object
|
|
@@ -147,6 +152,8 @@ def plan(rows, current, cooldowns=None, now=0.0, budget=None,
|
|
|
147
152
|
"capped": False,
|
|
148
153
|
"blocked_negative": False,
|
|
149
154
|
"regulation": None,
|
|
155
|
+
"proposals": [],
|
|
156
|
+
"selected": "",
|
|
150
157
|
"reason": "",
|
|
151
158
|
}
|
|
152
159
|
if not eligible:
|
|
@@ -159,12 +166,32 @@ def plan(rows, current, cooldowns=None, now=0.0, budget=None,
|
|
|
159
166
|
weights = [1.0] * len(eligible)
|
|
160
167
|
else:
|
|
161
168
|
weights = [weight for _key, _value, weight in eligible]
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
# Every recalled memory is a proposal with its own source id. Nothing is
|
|
170
|
+
# averaged: the turn follows the most trusted proposal (ties -> the larger
|
|
171
|
+
# pull), so a strong memory can never be diluted by a weak one.
|
|
172
|
+
proposals = []
|
|
173
|
+
for (key, pair, weight), mass in zip(eligible, weights):
|
|
174
|
+
proposals.append({"id": str(key),
|
|
175
|
+
"v": round(_clamp(pair[0], -1.0, 1.0), 4),
|
|
176
|
+
"a": round(_clamp(pair[1], 0.0, 1.0), 4),
|
|
177
|
+
"weight": round(_num(mass, 1.0), 4),
|
|
178
|
+
"delta_v": round(_clamp(
|
|
179
|
+
(pair[0] - before_v) * gain, -max_step, max_step), 6),
|
|
180
|
+
"delta_a": round(_clamp(
|
|
181
|
+
(pair[1] - before_a) * gain, -max_step, max_step), 6)})
|
|
182
|
+
report["proposals"] = proposals
|
|
183
|
+
if not split and proposals:
|
|
184
|
+
target_v = sum(row["v"] * row["weight"] for row in proposals) / max(
|
|
185
|
+
1e-9, sum(row["weight"] for row in proposals))
|
|
186
|
+
target_a = sum(row["a"] * row["weight"] for row in proposals) / max(
|
|
187
|
+
1e-9, sum(row["weight"] for row in proposals))
|
|
188
|
+
report["selected"] = ""
|
|
189
|
+
else:
|
|
190
|
+
winner = max(proposals,
|
|
191
|
+
key=lambda row: (row["weight"],
|
|
192
|
+
abs(row["delta_v"]) + abs(row["delta_a"])))
|
|
193
|
+
target_v, target_a = winner["v"], winner["a"]
|
|
194
|
+
report["selected"] = str(winner["id"])
|
|
168
195
|
target_v = _clamp(target_v, -1.0, 1.0)
|
|
169
196
|
target_a = _clamp(target_a, 0.0, 1.0)
|
|
170
197
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""0.
|
|
2
|
+
"""0.50/0.30/0.10/0.10 fusion (cue/semantic/importance/time) + 0.55/0.35 tiers.
|
|
3
|
+
|
|
4
|
+
The weights live in biocore_memory.core.config; this docstring used to say
|
|
5
|
+
0.6/0.4, which never matched the constants. [engineering]
|
|
6
|
+
"""
|
|
3
7
|
from __future__ import annotations
|
|
4
8
|
from ..core.config import (CUE_WEIGHT, HIGH_TIER, IMPORTANCE_WEIGHT, MID_TIER,
|
|
5
9
|
SEM_WEIGHT, TIME_BONUS)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""task-AstrBot-migration-1: pure memory scoring math moved out of main.py.
|
|
3
|
+
|
|
4
|
+
Behavior-preserving extraction of four host helpers:
|
|
5
|
+
memory_importance(core, role)
|
|
6
|
+
score_parts(core, entry, cosine, ...)
|
|
7
|
+
memory_score(core, entry, cosine, ...)
|
|
8
|
+
select_overflow_archive(entries, room, importance_max=0.5)
|
|
9
|
+
|
|
10
|
+
The host keeps thin wrappers that pass its own switches (salience/fan) and the
|
|
11
|
+
ACT_R_DECAY constant, so identical inputs give identical numbers.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
try: # package context (AstrBot plugin)
|
|
19
|
+
from .. import memory_meta
|
|
20
|
+
except Exception: # pragma: no cover - flat checkout
|
|
21
|
+
import memory_meta # type: ignore
|
|
22
|
+
|
|
23
|
+
ACT_R_DECAY_DEFAULT = 0.5
|
|
24
|
+
DEFAULT_COSINE = 0.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _num(value, default=0.0):
|
|
28
|
+
try:
|
|
29
|
+
number = float(value)
|
|
30
|
+
except Exception:
|
|
31
|
+
return default
|
|
32
|
+
return number if number == number else default
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def memory_importance(core, role):
|
|
36
|
+
"""Legacy importance heuristic; core None -> 0.5."""
|
|
37
|
+
if core is None:
|
|
38
|
+
return 0.5
|
|
39
|
+
salience = (0.25 + 0.35 * float(getattr(core, "a", 0.5))
|
|
40
|
+
+ 0.25 * abs(float(getattr(core, "v", 0.0))))
|
|
41
|
+
salience += 0.15 * (1.0 - float(getattr(core, "trust", 0.5)))
|
|
42
|
+
if role == "assistant":
|
|
43
|
+
salience *= 0.9
|
|
44
|
+
return max(0.15, min(1.0, salience))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def score_parts(core, entry, cosine, query_text="", hybrid=False,
|
|
48
|
+
sensitive_protect=False, *, salience_boost=False,
|
|
49
|
+
fan_penalty=False, fan_cap=32, fan_floor=0.70, fan_gain=0.10,
|
|
50
|
+
act_r_decay=ACT_R_DECAY_DEFAULT, now=None):
|
|
51
|
+
"""Deterministic score plus bounded component factors (same as main.py)."""
|
|
52
|
+
stamp = time.time() if now is None else float(now)
|
|
53
|
+
importance = memory_meta.effective_importance(entry)
|
|
54
|
+
age = max(0.0, stamp - float(entry.get("ts", 0) or 0))
|
|
55
|
+
age_days = age / 86400.0
|
|
56
|
+
retention_model = (getattr(core, "retention_model", "power_law")
|
|
57
|
+
if core is not None else "power_law")
|
|
58
|
+
if retention_model in ("power_law", "multi_scale"):
|
|
59
|
+
recency = memory_meta.retention_weight(age_days, retention_model)
|
|
60
|
+
else:
|
|
61
|
+
recency = 1.0 / math.pow(1.0 + max(0.0, age_days) * 0.35, 0.55)
|
|
62
|
+
try:
|
|
63
|
+
recall_count = int(entry.get("recall_count", 0) or 0)
|
|
64
|
+
except Exception:
|
|
65
|
+
recall_count = 0
|
|
66
|
+
assoc = memory_meta.decayed_assoc(entry)
|
|
67
|
+
try:
|
|
68
|
+
due = 1.15 if 0 < float(entry.get("next_review_ts", 0) or 0) <= stamp else 1.0
|
|
69
|
+
except Exception:
|
|
70
|
+
due = 1.0
|
|
71
|
+
try:
|
|
72
|
+
stability = max(0.2, float(entry.get("stability_days", 1.0) or 1.0))
|
|
73
|
+
except Exception:
|
|
74
|
+
stability = 1.0
|
|
75
|
+
retention = math.exp(-age_days / stability)
|
|
76
|
+
try:
|
|
77
|
+
_d = act_r_decay
|
|
78
|
+
_b = math.log(max(1e-9, (age_days + 0.05) ** (-_d)))
|
|
79
|
+
base = 1.0 / (1.0 + math.exp(-(_b + 1.0)))
|
|
80
|
+
except Exception:
|
|
81
|
+
base = 0.5
|
|
82
|
+
try:
|
|
83
|
+
schema = memory_meta.effective_schema_congruence(entry)
|
|
84
|
+
except Exception:
|
|
85
|
+
schema = 0.0
|
|
86
|
+
inhibition = memory_meta.retrieval_inhibition(entry)
|
|
87
|
+
regulation = (str(getattr(core, "regulation", "none") or "none")
|
|
88
|
+
if core is not None else "none")
|
|
89
|
+
regulation_mod = memory_meta.regulation_memory_modifier(entry, regulation)
|
|
90
|
+
lexical_mod = 1.0
|
|
91
|
+
if hybrid and query_text:
|
|
92
|
+
try:
|
|
93
|
+
entry_text = str(entry.get("text") or "")
|
|
94
|
+
variants = memory_meta.expand_query(query_text)
|
|
95
|
+
lexical = max(memory_meta.lexical_overlap(variant, entry_text)
|
|
96
|
+
for variant in variants) if variants else 0.0
|
|
97
|
+
lexical_mod = 1.0 + 0.12 * lexical
|
|
98
|
+
except Exception:
|
|
99
|
+
lexical_mod = 1.0
|
|
100
|
+
sensitive_mod = 1.0
|
|
101
|
+
if sensitive_protect:
|
|
102
|
+
sensitive_mod = memory_meta.sensitivity_weight(entry)
|
|
103
|
+
source_weight = memory_meta.retrieval_weight(entry)
|
|
104
|
+
score = (cosine * (0.40 + 0.60 * importance) * recency
|
|
105
|
+
* (0.65 + 0.35 * retention)
|
|
106
|
+
* (0.85 + 0.15 * base)
|
|
107
|
+
* (1.0 + 0.10 * math.log1p(max(0, recall_count)))
|
|
108
|
+
* due
|
|
109
|
+
* (1.0 + 0.05 * math.log1p(max(0, assoc)))
|
|
110
|
+
* source_weight
|
|
111
|
+
* (1.0 + 0.10 * schema)
|
|
112
|
+
* (1.0 - 0.15 * inhibition)
|
|
113
|
+
* regulation_mod
|
|
114
|
+
* lexical_mod
|
|
115
|
+
* sensitive_mod)
|
|
116
|
+
sal_factor = 1.0
|
|
117
|
+
if salience_boost:
|
|
118
|
+
try:
|
|
119
|
+
_c = max(0.0, min(1.0, float(cosine)))
|
|
120
|
+
if _c > 0.0:
|
|
121
|
+
sal_factor = 0.90 + 0.20 * _c
|
|
122
|
+
score *= sal_factor
|
|
123
|
+
except Exception:
|
|
124
|
+
sal_factor = 1.0
|
|
125
|
+
if fan_penalty:
|
|
126
|
+
try:
|
|
127
|
+
links = entry.get("links") if isinstance(entry.get("links"), list) else []
|
|
128
|
+
try:
|
|
129
|
+
raw_assoc = sum(int(v) for v in (entry.get("assoc") or {}).values())
|
|
130
|
+
except Exception:
|
|
131
|
+
raw_assoc = 0
|
|
132
|
+
fan = min(int(fan_cap or 32), raw_assoc + len(links))
|
|
133
|
+
penalty = max(float(fan_floor or 0.70),
|
|
134
|
+
1.0 / (1.0 + float(fan_gain or 0.10)
|
|
135
|
+
* math.log1p(max(0, fan))))
|
|
136
|
+
score *= penalty
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
return {
|
|
140
|
+
"score": float(score),
|
|
141
|
+
"salience": round(sal_factor, 4),
|
|
142
|
+
"cosine": round(float(cosine), 6),
|
|
143
|
+
"importance": round(importance, 4),
|
|
144
|
+
"recency": round(recency, 4),
|
|
145
|
+
"retention": round(0.65 + 0.35 * retention, 4),
|
|
146
|
+
"base": round(0.85 + 0.15 * base, 4),
|
|
147
|
+
"recall_boost": round(1.0 + 0.10 * math.log1p(max(0, recall_count)), 4),
|
|
148
|
+
"assoc_boost": round(1.0 + 0.05 * math.log1p(max(0, assoc)), 4),
|
|
149
|
+
"due": round(due, 4),
|
|
150
|
+
"source_weight": round(float(source_weight), 4),
|
|
151
|
+
"schema": round(schema, 4),
|
|
152
|
+
"inhibition": round(float(inhibition), 4),
|
|
153
|
+
"regulation": round(float(regulation_mod), 4),
|
|
154
|
+
"lexical": round(float(lexical_mod), 4),
|
|
155
|
+
"sensitive": round(float(sensitive_mod), 4),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def memory_score(core, entry, cosine, query_text="", hybrid=False,
|
|
160
|
+
sensitive_protect=False, *, salience_boost=False,
|
|
161
|
+
fan_penalty=False, fan_cap=32, fan_floor=0.70, fan_gain=0.10,
|
|
162
|
+
act_r_decay=ACT_R_DECAY_DEFAULT, now=None):
|
|
163
|
+
parts = score_parts(
|
|
164
|
+
core, entry, cosine, query_text=query_text, hybrid=hybrid,
|
|
165
|
+
sensitive_protect=sensitive_protect, salience_boost=salience_boost,
|
|
166
|
+
fan_penalty=fan_penalty, fan_cap=fan_cap, fan_floor=fan_floor,
|
|
167
|
+
fan_gain=fan_gain, act_r_decay=act_r_decay, now=now)
|
|
168
|
+
score = float(parts.get("score", 0.0))
|
|
169
|
+
try:
|
|
170
|
+
trace = getattr(core, "_last_memory_trace", None) if core is not None else None
|
|
171
|
+
if isinstance(trace, dict):
|
|
172
|
+
entry_id = str(entry.get("id") or "")
|
|
173
|
+
if entry_id and entry_id in [str(x) for x in (trace.get("entry_ids") or [])]:
|
|
174
|
+
valence = max(-1.0, min(1.0, float(trace.get("valence", 0.0) or 0.0)))
|
|
175
|
+
score *= 1.0 + 0.04 * valence
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
return max(0.0, score)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def select_overflow_archive(entries, room, importance_max=0.5):
|
|
182
|
+
"""Deterministic low-importance unpinned overflow selection."""
|
|
183
|
+
rows = [row for row in (entries or []) if isinstance(row, dict)]
|
|
184
|
+
rows.sort(key=lambda row: float(row.get("ts", 0) or 0))
|
|
185
|
+
try:
|
|
186
|
+
keep_n = max(0, int(room or 0))
|
|
187
|
+
except Exception:
|
|
188
|
+
keep_n = 80
|
|
189
|
+
keep = rows[-keep_n:] if keep_n else []
|
|
190
|
+
overflow = []
|
|
191
|
+
for row in rows:
|
|
192
|
+
if row in keep:
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
importance = float(row.get("importance", 0.5) or 0.5)
|
|
196
|
+
except Exception:
|
|
197
|
+
importance = 0.5
|
|
198
|
+
if importance < float(importance_max) and not memory_meta.is_pinned(row):
|
|
199
|
+
overflow.append(row)
|
|
200
|
+
else:
|
|
201
|
+
keep.append(row)
|
|
202
|
+
keep.sort(key=lambda row: float(row.get("ts", 0) or 0))
|
|
203
|
+
return keep, overflow
|