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.
Files changed (52) hide show
  1. package/lib/modules/state.js +22 -3
  2. package/package.json +1 -1
  3. package/python/biocore_memory/core/config.py +7 -3
  4. package/python/biocore_memory/engine.py +91 -6
  5. package/python/biocore_memory/recall/embedding.py +0 -51
  6. package/python/biocore_memory/recall/feedback.py +34 -7
  7. package/python/biocore_memory/recall/fusion.py +5 -1
  8. package/python/biocore_memory/scoring.py +203 -0
  9. package/python/biocore_memory/space.py +355 -0
  10. package/python/biocore_memory/store/dedupe.py +1 -0
  11. package/python/biocore_model/affective_labor.py +14 -1
  12. package/python/biocore_model/appraisal.py +172 -3
  13. package/python/biocore_model/bio_tick.py +43 -13
  14. package/python/biocore_model/emotion_geometry.py +9 -0
  15. package/python/biocore_model/envy.py +21 -0
  16. package/python/biocore_model/feedback_memory.py +53 -0
  17. package/python/biocore_model/needs_events.py +32 -11
  18. package/python/biocore_model/regulation_strategies.py +5 -0
  19. package/python/biocore_model/repair_after_boundary.py +1 -0
  20. package/python/biocore_model/self_esteem.py +14 -2
  21. package/python/biocore_model/self_intent.py +6 -4
  22. package/python/biocore_model/tests/test_autonomy_self.py +153 -153
  23. package/python/biocore_model/tests/test_cache_series.py +137 -137
  24. package/python/biocore_model/tests/test_core_state.py +240 -240
  25. package/python/biocore_model/tests/test_extensions.py +167 -167
  26. package/python/biocore_model/tests/test_field_arbiter.py +444 -0
  27. package/python/biocore_model/tests/test_governance.py +181 -181
  28. package/python/biocore_model/tests/test_lifecycle.py +153 -153
  29. package/python/biocore_model/tests/test_memory_inject.py +211 -211
  30. package/python/biocore_model/tests/test_retrieval_plus.py +143 -143
  31. package/python/biocore_model/tests/test_runtime_pipeline.py +1 -1
  32. package/python/biocore_model/tests/test_signals.py +106 -106
  33. package/python/biocore_model/tests/test_standby.py +199 -199
  34. package/python/biocore_runtime/arbiter.py +307 -0
  35. package/python/biocore_runtime/batch_b.py +724 -0
  36. package/python/biocore_runtime/batch_c.py +186 -0
  37. package/python/biocore_runtime/batch_d.py +659 -0
  38. package/python/biocore_runtime/batch_e.py +328 -0
  39. package/python/biocore_runtime/batch_registry.py +446 -0
  40. package/python/biocore_runtime/core_state.py +106 -3
  41. package/python/biocore_runtime/dimensions.py +419 -0
  42. package/python/biocore_runtime/expression.py +261 -0
  43. package/python/biocore_runtime/field.py +440 -0
  44. package/python/biocore_runtime/perception.py +1045 -0
  45. package/python/biocore_runtime/pipeline.py +4458 -3105
  46. package/python/biocore_runtime/priority.py +210 -0
  47. package/python/biocore_runtime/standby.py +581 -579
  48. package/python/biocore_runtime/timescales.py +263 -0
  49. package/ui/panel-layout.js +32 -29
  50. package/ui/panel.css +563 -974
  51. package/ui/panel.html +427 -401
  52. package/ui/panel.js +543 -450
@@ -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
- export const STATE_LIMIT = 16 * 1024
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
- /** True when the serialized payload fits the 16KB /state budget. */
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
- return Buffer.byteLength(JSON.stringify(body), 'utf8') <= STATE_LIMIT
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-biocore",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "BioCore DSH UI: per-session state tab, standalone instrument panel, /api/biocore/state, session/event one_turn updates, /bio sync.",
6
6
  "main": "lib/index.js",
@@ -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 = getattr(self, "affect", None)
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) else getattr(self, "affect", None)
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(getattr(self, "affect", None))
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 to self.affect, dict or object; never raises."""
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
- if getattr(self, "affect", None) is None:
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], self.affect, self._feedback_last,
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
- 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
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.6/0.4 fusion + 0.55/0.35 tiers."""
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