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
package/lib/index.js CHANGED
@@ -28,6 +28,7 @@ import { SERIES_ROUTE_PATH, handleSeriesRoute } from './modules/series.js'
28
28
  import { readCacheStats } from './modules/cache-stats.js'
29
29
  import { startCoreWorker, stopCoreWorker } from './core-worker.js'
30
30
  import { runAutonomyTick } from './modules/autonomy.js'
31
+ import { drainOutbox } from './modules/standby.js'
31
32
  import { queueSessionTurn } from './modules/turn-queue.js'
32
33
  import { makeCoreSessionListener, registerCoreTurn } from './modules/core-turn.js'
33
34
  import { migrateLegacyPluginData, PLUGIN_DATA_DIR, resolveDataDir } from './modules/data-dir.js'
@@ -193,6 +194,8 @@ export function readFullConfig(dataDir) {
193
194
  rerank: normalizeRerank(source.rerank),
194
195
  expression: normalizeExpression(source.expression),
195
196
  autonomy: normalizeAutonomy(source.autonomy),
197
+ standby: source.standby && typeof source.standby === 'object' ? source.standby : {},
198
+ standbyEnabled: source.standbyEnabled === true,
196
199
  sanitize_reply: source.sanitize_reply !== false,
197
200
  }
198
201
  }
@@ -389,6 +392,10 @@ function coreConfigPayload(dataDir) {
389
392
  rerank: config.rerank,
390
393
  expression: config.expression,
391
394
  autonomy: config.autonomy,
395
+ standby: {
396
+ ...(config.standby && typeof config.standby === 'object' ? config.standby : {}),
397
+ standby_enabled: config.standbyEnabled === true,
398
+ },
392
399
  sanitize_reply: config.sanitize_reply !== false,
393
400
  switches,
394
401
  recall_mode: 'query',
@@ -762,8 +769,10 @@ export async function handleStats(req, res, options) {
762
769
  } catch { reply = null }
763
770
  const body = Object.assign({ ok: false, reason: 'core_unavailable' },
764
771
  reply && typeof reply === 'object' ? reply : {})
772
+ // contract migration 1: the core computes cache stats; the legacy file is a
773
+ // read-only fallback for one week, never a source of new numbers.
765
774
  try {
766
- if (typeof opts.dataDir === 'string' && opts.dataDir) {
775
+ if (!body.cache && typeof opts.dataDir === 'string' && opts.dataDir) {
767
776
  body.cache = readCacheStats(opts.dataDir)
768
777
  }
769
778
  } catch { /* display only */ }
@@ -992,6 +1001,26 @@ export function apply(ctx, config) {
992
1001
  keep(() => { if (timer !== null) { clearInterval(timer); timer = null } })
993
1002
  }
994
1003
 
1004
+ // task-215: standby is off by default; when on, the core writes the outbox
1005
+ // and this adapter only drains it into results.
1006
+ if (config && config.standbyEnabled === true) {
1007
+ let standbyTimer = null
1008
+ const standbyTick = async () => {
1009
+ try {
1010
+ await runtimeAction('standby_tick', {
1011
+ store: resolveMemoryDir(dataDir),
1012
+ data_dir: dataDir,
1013
+ session_id: 'standby',
1014
+ now: Date.now() / 1000,
1015
+ })
1016
+ drainOutbox(dataDir)
1017
+ } catch { /* standby never disturbs a turn */ }
1018
+ }
1019
+ standbyTimer = setInterval(() => { void standbyTick() }, 5 * 60 * 1000)
1020
+ if (typeof standbyTimer.unref === 'function') standbyTimer.unref()
1021
+ keep(() => { if (standbyTimer !== null) { clearInterval(standbyTimer); standbyTimer = null } })
1022
+ }
1023
+
995
1024
  return () => {
996
1025
  for (const dispose of disposers) {
997
1026
  try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
@@ -0,0 +1,61 @@
1
+ /** task-215: adapter side of standby -- read outbox, record results only.
2
+ *
3
+ * The core writes <dataDir>/proactive/outbox.jsonl. This module never decides
4
+ * whether to speak and never sends externally; it records one execution result
5
+ * per outbox row into <dataDir>/proactive/results.jsonl.
6
+ */
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
8
+ import { dirname, join } from 'node:path'
9
+
10
+ export function proactiveDir(dataDir) {
11
+ return join(String(dataDir || '').trim(), 'proactive')
12
+ }
13
+
14
+ export function outboxPath(dataDir) {
15
+ return join(proactiveDir(dataDir), 'outbox.jsonl')
16
+ }
17
+
18
+ export function resultsPath(dataDir) {
19
+ return join(proactiveDir(dataDir), 'results.jsonl')
20
+ }
21
+
22
+ function readLines(path) {
23
+ try {
24
+ if (!existsSync(path)) return []
25
+ return readFileSync(path, 'utf8').split('\n').filter(Boolean)
26
+ } catch {
27
+ return []
28
+ }
29
+ }
30
+
31
+ /** Record never-seen outbox rows; returns how many were recorded. */
32
+ export function drainOutbox(dataDir, options) {
33
+ const opts = options || {}
34
+ const rows = readLines(outboxPath(dataDir))
35
+ const done = readLines(resultsPath(dataDir)).length
36
+ if (rows.length <= done) return { ok: true, recorded: 0, pending: 0 }
37
+ const path = resultsPath(dataDir)
38
+ try {
39
+ mkdirSync(dirname(path), { recursive: true })
40
+ for (let index = done; index < rows.length; index += 1) {
41
+ let row = {}
42
+ try { row = JSON.parse(rows[index]) } catch { row = {} }
43
+ appendFileSync(path, JSON.stringify({
44
+ ts: row.ts || 0,
45
+ index,
46
+ delivered: false,
47
+ reason: 'outbox_only',
48
+ result: { ok: true, external: false },
49
+ }) + '\n', 'utf8')
50
+ }
51
+ return { ok: true, recorded: rows.length - done, pending: 0 }
52
+ } catch {
53
+ return { ok: false, recorded: 0, pending: rows.length - done }
54
+ }
55
+ }
56
+
57
+ export function standbySummary(dataDir) {
58
+ const outbox = readLines(outboxPath(dataDir)).length
59
+ const results = readLines(resultsPath(dataDir)).length
60
+ return { pending: Math.max(0, outbox - results), outbox, results }
61
+ }
@@ -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.3.3",
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)