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
@@ -26,8 +26,15 @@ import re
26
26
 
27
27
  DIMS = ("goal", "blame", "control", "certainty", "novelty", "involve", "hit")
28
28
  OUTPUTS = ("v", "a", "d", "social", "trust", "stress", "self_esteem")
29
+ # [engineering] per-turn output caps, hand-tuned.
29
30
  CAPS = {"v": 0.15, "a": 0.15, "d": 0.10, "social": 0.05,
30
31
  "trust": 0.05, "stress": 0.10, "self_esteem": 0.05}
32
+ # appraisal_v2 (default ON): v is goal + an explicit valence signal only.
33
+ # blame goes to trust/conflict, hit to stress/self_esteem, and control /
34
+ # certainty / novelty / involve each keep their own output line. The old 7x7
35
+ # mapping stays reachable with BIOCORE_APPRAISAL_V2=0 or flags["appraisal_v2"].
36
+ SWITCH_KEY = "appraisal_v2"
37
+ ENV_KEY = "BIOCORE_APPRAISAL_V2"
31
38
  PROBE_MIN_CONFIDENCE = 0.4
32
39
  WHY_MAX = 40
33
40
  INVOLVE_GAIN = 0.5
@@ -37,7 +44,9 @@ INVOLVE_GAIN = 0.5
37
44
  FACT_MAX_CHARS = 20
38
45
  SITUATION_KEYS = ("challenge", "praise", "hurt")
39
46
 
40
- # 7 x 7 W matrix: WEIGHTS[dimension][output] = weight.
47
+ # 7 x 7 W matrix: WEIGHTS[dimension][output] = weight. [engineering] the
48
+ # dimension names follow Scherer 2001 / OCC, but no row below is fitted or
49
+ # transcribed from a paper (see PAPER-MAP.md).
41
50
  # goal: -1 blocked .. +1 achieved -> better valence/trust, less stress.
42
51
  # blame: 0 self .. 1 other -> conflict, lower self-esteem/trust, more stress.
43
52
  # control: 0 powerless .. 1 in control -> dominance/self-esteem, less stress.
@@ -64,6 +73,38 @@ WEIGHTS = {
64
73
  }
65
74
  DEFAULT_WEIGHTS = {"%s.%s" % (dim, out): weight
66
75
  for dim, row in WEIGHTS.items() for out, weight in row.items()}
76
+
77
+ # v2 destination table. Every dimension has exactly one home (goal and the
78
+ # explicit valence signal own v); nothing is folded into v a second time.
79
+ V2_WEIGHTS = {
80
+ "v": {"goal": 0.60, "valence": 0.40},
81
+ "a": {"novelty": 0.25, "involve": 0.10, "hit": 0.20},
82
+ "d": {"control": 0.45, "goal": 0.20},
83
+ "social": {"involve": 0.20, "hit": -0.30},
84
+ "trust": {"goal": 0.15, "blame": -0.30},
85
+ "conflict": {"blame": 0.35, "hit": 0.10},
86
+ "stress": {"blame": 0.15, "hit": 0.30},
87
+ "self_esteem": {"goal": 0.20, "hit": -0.30},
88
+ }
89
+ V2_OUTPUTS = ("v", "a", "d", "social", "trust", "conflict", "stress",
90
+ "self_esteem")
91
+ V2_CAPS = dict(CAPS, conflict=0.15)
92
+
93
+
94
+ def enabled(flags=None, env=None):
95
+ """appraisal_v2: explicit flag > nested switches > env > default ON."""
96
+ import os
97
+ data = flags if isinstance(flags, dict) else {}
98
+ if isinstance(data.get(SWITCH_KEY), bool):
99
+ return data[SWITCH_KEY]
100
+ switches = data.get("switches") if isinstance(data.get("switches"), dict) else {}
101
+ if isinstance(switches.get(SWITCH_KEY), bool):
102
+ return switches[SWITCH_KEY]
103
+ environ = env if hasattr(env, "get") else os.environ
104
+ raw = str(environ.get(ENV_KEY) or "").strip().lower()
105
+ if raw in ("0", "false", "off", "no"):
106
+ return False
107
+ return True
67
108
  _FEEDBACK_FIELDS = {"v": "valence_delta", "a": "arousal_delta",
68
109
  "d": "dominance_delta", "social": "social_delta",
69
110
  "trust": "trust_delta", "stress": "stress_delta",
@@ -182,8 +223,62 @@ async def probe(text, context, llm_call, timeout=None, core=None, now=None):
182
223
  return {}
183
224
 
184
225
 
185
- def appraise(dims, confidence=0.5, weights=None):
186
- """W·x scaled by confidence, bounded by per-output caps."""
226
+ def _v2_values(dims):
227
+ """Inputs for the v2 table: the 7 dims plus an optional explicit valence."""
228
+ dims = dims if isinstance(dims, dict) else {}
229
+ values = {}
230
+ for name in DIMS:
231
+ low = -1.0 if name == "goal" else 0.0
232
+ values[name] = _clamp(dims.get(name, 0.0), low, 1.0)
233
+ values["valence"] = _clamp(dims.get("valence", 0.0), -1.0, 1.0)
234
+ return values
235
+
236
+
237
+ def _appraise_v2(dims, confidence=0.5, weights=None):
238
+ """v2 mapping: (deltas, components). Nothing is added into v twice."""
239
+ conf = _clamp(confidence, 0.0, 1.0)
240
+ values = _v2_values(dims)
241
+ table = {out: dict(row) for out, row in V2_WEIGHTS.items()}
242
+ if isinstance(weights, dict):
243
+ for key, value in weights.items():
244
+ if "." not in key:
245
+ continue
246
+ dim, output = key.split(".", 1)
247
+ number = _num(value)
248
+ if dim in values and output in table and number is not None:
249
+ table[output][dim] = number
250
+ deltas = {}
251
+ components = {}
252
+ for output in V2_OUTPUTS:
253
+ rows = {}
254
+ total = 0.0
255
+ for name, weight in (table.get(output) or {}).items():
256
+ part = float(weight) * values.get(name, 0.0) * conf
257
+ if abs(part) > 1e-9:
258
+ rows[name] = round(part, 6)
259
+ total += part
260
+ cap = float(V2_CAPS.get(output, 0.2))
261
+ capped = max(-cap, min(cap, total))
262
+ components[output] = {"rows": rows, "total": round(total, 6),
263
+ "capped": round(capped, 6),
264
+ "dropped": round(total - capped, 6)}
265
+ if abs(capped) > 1e-9:
266
+ deltas[output] = round(capped, 4)
267
+ return deltas, components
268
+
269
+
270
+ def appraise(dims, confidence=0.5, weights=None, v2=None):
271
+ """W·x scaled by confidence, bounded by per-output caps.
272
+
273
+ v2 (default ON) uses V2_WEIGHTS: v = goal + explicit valence only, blame
274
+ and hit go to their own outputs. v2=False is the old 7x7 mapping, byte for
275
+ byte.
276
+ """
277
+ if v2 is None:
278
+ v2 = enabled()
279
+ if v2:
280
+ deltas, _components = _appraise_v2(dims, confidence, weights)
281
+ return deltas
187
282
  dims = dims if isinstance(dims, dict) else {}
188
283
  conf = _clamp(confidence, 0.0, 1.0)
189
284
  w = {dim: dict(row) for dim, row in WEIGHTS.items()}
@@ -214,6 +309,80 @@ def appraise(dims, confidence=0.5, weights=None):
214
309
  return out
215
310
 
216
311
 
312
+ def appraise_detail(dims, confidence=0.5, weights=None, v2=None):
313
+ """Same numbers as appraise(), plus where each one came from.
314
+
315
+ components[output][dim] is that dim's own contribution, so v/a are visible
316
+ derived readouts instead of a black box that quietly folds blame/hit in.
317
+ Nothing is merged: the caller can show every source line.
318
+ """
319
+ if v2 is None:
320
+ v2 = enabled()
321
+ if v2:
322
+ deltas, components = _appraise_v2(dims, confidence, weights)
323
+ return {"deltas": deltas, "components": components,
324
+ "mapping": "v2",
325
+ "derived": {"v": True, "a": False}}
326
+ dims = dims if isinstance(dims, dict) else {}
327
+ conf = _clamp(confidence, 0.0, 1.0)
328
+ w = {dim: dict(row) for dim, row in WEIGHTS.items()}
329
+ if isinstance(weights, dict):
330
+ for key, value in weights.items():
331
+ if "." not in key:
332
+ continue
333
+ dim, output = key.split(".", 1)
334
+ number = _num(value)
335
+ if dim in w and output in w[dim] and number is not None:
336
+ w[dim][output] = number
337
+ values = {}
338
+ for name in DIMS:
339
+ low = -1.0 if name == "goal" else 0.0
340
+ values[name] = _clamp(dims.get(name, 0.0), low, 1.0)
341
+ gain = 1.0 + INVOLVE_GAIN * values["involve"]
342
+ components = {}
343
+ deltas = {}
344
+ for output in OUTPUTS:
345
+ rows = {}
346
+ total = 0.0
347
+ for name in DIMS:
348
+ part = float(w[name].get(output, 0.0)) * values[name] * gain * conf
349
+ if abs(part) > 1e-9:
350
+ rows[name] = round(part, 6)
351
+ total += part
352
+ cap = float(CAPS[output])
353
+ capped = max(-cap, min(cap, total))
354
+ components[output] = {"rows": rows,
355
+ "total": round(total, 6),
356
+ "capped": round(capped, 6),
357
+ "dropped": round(total - capped, 6)}
358
+ if abs(capped) > 1e-9:
359
+ deltas[output] = round(capped, 4)
360
+ return {"deltas": deltas, "components": components,
361
+ "mapping": "v1", "derived": {key: True for key in ("v", "a")}}
362
+
363
+
364
+ def legacy_v(dims, confidence=0.5, weights=None):
365
+ """The old 7x7 v (v1), kept for comparison only -- never written back."""
366
+ out = appraise(dims, confidence=confidence, weights=weights, v2=False)
367
+ return out.get("v")
368
+
369
+
370
+ def appraise_with_legacy(dims, confidence=0.5, weights=None, v2=None):
371
+ """{deltas, v, v_legacy, mapping} for /state.appraisal.
372
+
373
+ With v2 on, v_legacy is what the old mapping would have produced, so the
374
+ two can be compared without changing anything. With v2 off there is no
375
+ second mapping and v_legacy is None.
376
+ """
377
+ if v2 is None:
378
+ v2 = enabled()
379
+ deltas = appraise(dims, confidence=confidence, weights=weights, v2=v2)
380
+ return {"deltas": deltas,
381
+ "v": deltas.get("v", 0.0),
382
+ "v_legacy": legacy_v(dims, confidence, weights) if v2 else None,
383
+ "mapping": "v2" if v2 else "v1"}
384
+
385
+
217
386
  def appraise_dims(dims, weights=None):
218
387
  """Compatibility: full-confidence mapping (no confidence scaling)."""
219
388
  return appraise(dims, confidence=1.0, weights=weights)
@@ -10,7 +10,8 @@ Literature
10
10
  Craig 2009 (insula interoception), Barrett 2017 (constructed emotion),
11
11
  Seth 2013 (interoceptive prediction), Garfinkel 2015 (three dimensions),
12
12
  Sterling 2012 (allostasis), Carver & Scheier 1998 (cybernetic control),
13
- Powers 1973 (perceptual control), Gross 1998 (regulation strategies).
13
+ Powers 1973 (perceptual control), Gross 1998 (regulation strategies),
14
+ Anderson & Schooler 1991 (power-law forgetting, ACT-R activation).
14
15
 
15
16
  Hard constraints: pure functions, stdlib only, no main import, no network, no
16
17
  model call; the clock is injected (now / dt_hours); repeated calls with the same
@@ -32,9 +33,12 @@ DT_CAP_HOURS = 6.0
32
33
  DISCOMFORT_TRIGGER = 0.35
33
34
  LOG_EVERY = 10
34
35
  LOG_ENV = "BIOCORE_BIO_TICK_LOG"
35
- DEFAULT_VALENCE_DECAY_MINUTES = 120.0
36
- DEFAULT_AROUSAL_DECAY_MINUTES = 30.0
37
- DEFAULT_SOCIAL_DECAY_MINUTES = 180.0
36
+ DEFAULT_VALENCE_DECAY_MINUTES = 120.0 # [engineering] hand-tuned
37
+ DEFAULT_AROUSAL_DECAY_MINUTES = 30.0 # [engineering] hand-tuned
38
+ DEFAULT_SOCIAL_DECAY_MINUTES = 180.0 # [engineering] hand-tuned
39
+ # Anderson & Schooler 1991, Psychological Science 2:396-408 (ACT-R activation):
40
+ # forgetting follows a power law, not an exponential; d ~= 0.5 in their fits.
41
+ POWER_LAW_EXPONENT = 0.5
38
42
 
39
43
 
40
44
  def _num(value, default=0.0):
@@ -78,12 +82,36 @@ def _snapshot(core):
78
82
  return {}
79
83
 
80
84
 
81
- def _half_life_factor(minutes, half_life):
85
+ def _power_law_factor(minutes, half_life, exponent=None):
86
+ """Anderson & Schooler 1991 power law of forgetting: (1 + t / tau) ** -d.
87
+
88
+ tau is solved from the configured half-life (tau = half_life / (2**(1/d)-1))
89
+ so a caller's half-life still means a half-life, while the tail decays like
90
+ an activation trace instead of dropping geometrically. d is configurable
91
+ per core through the decay_exponent attribute.
92
+ """
82
93
  try:
83
94
  hl = max(1.0, float(half_life))
84
95
  except Exception:
85
96
  hl = 120.0
86
- return 0.5 ** (max(0.0, float(minutes)) / hl)
97
+ try:
98
+ power = POWER_LAW_EXPONENT if exponent is None else float(exponent)
99
+ except Exception:
100
+ power = POWER_LAW_EXPONENT
101
+ d = max(0.05, min(2.0, power))
102
+ try:
103
+ span = max(1e-6, 2.0 ** (1.0 / d) - 1.0)
104
+ except Exception:
105
+ span = 3.0
106
+ tau = hl / span
107
+ try:
108
+ minutes = max(0.0, float(minutes))
109
+ except Exception:
110
+ minutes = 0.0
111
+ return (1.0 + minutes / tau) ** (-d)
112
+
113
+
114
+ _half_life_factor = _power_law_factor # older callers keep working
87
115
 
88
116
 
89
117
  def _derive(core):
@@ -147,13 +175,15 @@ def _decay(core, dt_hours):
147
175
  v_rec = recovery if v < 0 else 1.0
148
176
  a_rec = recovery if a > 0.40 else 1.0
149
177
  s_rec = recovery if soc < 0.55 else 1.0
150
- _put(core, "v", _clip(v * _half_life_factor(minutes, v_hl * emo_factor / v_rec), -1, 1))
151
- _put(core, "a", _clip(0.40 + (a - 0.40) * _half_life_factor(
152
- minutes, a_hl * max(0.5, emo_factor) / a_rec), 0, 1))
153
- _put(core, "d", _clip(0.55 + (d - 0.55) * _half_life_factor(
154
- minutes, v_hl * emo_factor), -1, 1))
155
- _put(core, "social", _clip(0.55 + (soc - 0.55) * _half_life_factor(
156
- minutes, s_hl * emo_factor / s_rec), 0, 1))
178
+ power = _get(core, "decay_exponent", POWER_LAW_EXPONENT)
179
+ _put(core, "v", _clip(v * _power_law_factor(
180
+ minutes, v_hl * emo_factor / v_rec, power), -1, 1))
181
+ _put(core, "a", _clip(0.40 + (a - 0.40) * _power_law_factor(
182
+ minutes, a_hl * max(0.5, emo_factor) / a_rec, power), 0, 1))
183
+ _put(core, "d", _clip(0.55 + (d - 0.55) * _power_law_factor(
184
+ minutes, v_hl * emo_factor, power), -1, 1))
185
+ _put(core, "social", _clip(0.55 + (soc - 0.55) * _power_law_factor(
186
+ minutes, s_hl * emo_factor / s_rec, power), 0, 1))
157
187
  _derive(core)
158
188
  return True
159
189
 
@@ -23,6 +23,8 @@ PROTOTYPES = {
23
23
  "loneliness": {"v": -0.5, "a": -0.4, "d": -0.3, "social": -0.7, "stress": 0.2},
24
24
  "trust": {"v": 0.4, "a": 0.1, "d": 0.2, "social": 0.5, "trust": 0.7},
25
25
  }
26
+ # [engineering] six-axis weights and the distance formula have no source;
27
+ # the axis choice echoes Russell 1980 / Mehrabian 1996 (see PAPER-MAP.md).
26
28
  WEIGHTS = {"v": 0.35, "a": 0.25, "d": 0.15, "social": 0.15,
27
29
  "stress": 0.10, "trust": 0.15}
28
30
  CORE_DIMS = ("v", "a", "d", "social", "stress", "trust")
@@ -90,7 +92,14 @@ def classify(state, temperature=0.25):
90
92
  coverage = present_weight / total_weight if total_weight > 0 else 1.0
91
93
  # Missing dimensions always reduce confidence; neutral fill is not evidence.
92
94
  confidence *= max(0.0, min(1.0, coverage))
95
+ # Raw per-emotion closeness (1 - distance) stays visible; the softmax
96
+ # probability is a DISPLAY label only, never fed back into a decision.
97
+ scores = {name: round(max(0.0, 1.0 - distance), 4)
98
+ for name, distance in distances.items()}
93
99
  return {"label": label,
100
+ "scores": scores,
101
+ "distances": {name: round(value, 4)
102
+ for name, value in distances.items()},
94
103
  "probs": {name: round(prob, 4) for name, prob in probs.items()},
95
104
  "confidence": round(max(0.0, min(1.0, confidence)), 4),
96
105
  "top2": top2,
@@ -113,6 +113,26 @@ def _normalize(signals):
113
113
  }
114
114
 
115
115
 
116
+ def _components(norm):
117
+ """Every signal behind the intensity, each on its own line.
118
+
119
+ intensity is kept as a display readout; the components are the evidence,
120
+ so "comparison" and "self-threat" are never fused into one number.
121
+ """
122
+ norm = norm if isinstance(norm, dict) else {}
123
+ closeness = _clamp01(norm.get("closeness", 0.5), 0.5)
124
+ fairness = _clamp01(norm.get("fairness", 0.5), 0.5)
125
+ third = _clamp01(norm.get("third_party", 0.0), 0.0)
126
+ self_eval = _clamp01(norm.get("self_evaluation", 0.5), 0.5)
127
+ return {
128
+ "comparison": round(third, 4),
129
+ "relative_deprivation": round(1.0 - fairness, 4),
130
+ "self_threat": round(1.0 - self_eval, 4),
131
+ "closeness": round(closeness, 4),
132
+ "presence": round(third * (0.5 + 0.5 * closeness), 4),
133
+ }
134
+
135
+
116
136
  def intensity_of(signals):
117
137
  """0..1 comparison intensity; the single number behind the three levels."""
118
138
  norm = _normalize(signals)
@@ -163,6 +183,7 @@ def assess(signals, config=None):
163
183
  "behavior": "",
164
184
  "reason": REASONS[""],
165
185
  "signals": norm,
186
+ "components": _components(norm),
166
187
  }
167
188
  if not out["enabled"]:
168
189
  out["reason"] = REASONS["disabled"]
@@ -14,6 +14,7 @@ BOOST = 0.25
14
14
  CORE_AFTER = 5
15
15
  D = 0.5
16
16
  TAU_HOURS = 24.0
17
+ COOLDOWN_SECONDS = 3600.0 # [engineering] one boost per memory per hour
17
18
 
18
19
 
19
20
  def _num(value, default=0.0):
@@ -50,6 +51,58 @@ def touch(entries, event_id, now, kind="recall"):
50
51
  return {"entries": items, "deltas": {}, "audit": [], "idempotent": False}
51
52
 
52
53
 
54
+ def touch_batch(entries, event_ids, now, kind="recall", limit=None):
55
+ """Touch several recalled memories: one row per entry, never one aggregate.
56
+
57
+ Each entry keeps its own old/new/boost row; an entry already touched at or
58
+ after `now` is skipped (cooldown), and `limit` caps how many may move in
59
+ one turn. Returns {"entries", "rows", "deltas", "skipped"}.
60
+ """
61
+ items = [dict(entry or {}) for entry in (entries or [])]
62
+ wanted = [str(item) for item in (event_ids or []) if str(item or "")]
63
+ if limit is None:
64
+ limit = len(wanted) or 0
65
+ rows = []
66
+ skipped = []
67
+ moved = 0
68
+ for target in wanted:
69
+ if moved >= max(0, int(limit)):
70
+ skipped.append({"id": target, "reason": "turn_cap"})
71
+ continue
72
+ found = None
73
+ for entry in items:
74
+ if str(entry.get("id")) == target:
75
+ found = entry
76
+ break
77
+ if found is None:
78
+ skipped.append({"id": target, "reason": "not_found"})
79
+ continue
80
+ last_touch = float(found.get("last_touch_ts", 0.0) or 0.0)
81
+ if last_touch > 0 and float(now) - last_touch < COOLDOWN_SECONDS:
82
+ skipped.append({"id": target, "reason": "cooldown"})
83
+ continue
84
+ old = _num(found.get("strength"), 1.0)
85
+ found["strength"] = round(min(1.0, old + BOOST), 4)
86
+ found["recall_count"] = int(found.get("recall_count", 0) or 0) + 1
87
+ found["last_touched"] = float(now)
88
+ found["last_decay_ts"] = float(now)
89
+ found["last_touch_ts"] = float(now)
90
+ found["last_touch_kind"] = str(kind)
91
+ if found["recall_count"] > CORE_AFTER:
92
+ found["core_memory"] = True
93
+ rows.append({"id": target, "old": round(old, 4),
94
+ "new": found["strength"],
95
+ "boost": round(found["strength"] - old, 4),
96
+ "recall_count": found["recall_count"],
97
+ "source": "feedback_memory", "reason": str(kind)})
98
+ moved += 1
99
+ deltas = {row["id"]: {"strength": row["new"],
100
+ "recall_count": row["recall_count"]}
101
+ for row in rows}
102
+ return {"entries": items, "rows": rows, "deltas": deltas,
103
+ "skipped": skipped, "idempotent": not rows}
104
+
105
+
53
106
  def decay_all(entries, now, d=D, tau_hours=TAU_HOURS):
54
107
  items = []
55
108
  forgotten = []
@@ -108,30 +108,51 @@ def apply(needs, events, scale=1.0):
108
108
  "events": applied, "scale": round(factor, 3)}
109
109
 
110
110
 
111
- def behavior_bias(needs, baseline=BASELINE):
112
- """Need deficits -> 0..1 behavioural biases (expression/inclination)."""
111
+ def need_biases(needs, baseline=BASELINE):
112
+ """One row per need: value / deficit / surplus / bias.
113
+
114
+ Each need keeps its own bias; nothing is combined into one number, so
115
+ reading belonging never tells you anything about control.
116
+ """
113
117
  state = needs_state(needs)
114
118
  base = _clamp(baseline, 0.0, 1.0)
119
+ rows = {}
120
+ for key in NEED_KEYS:
121
+ deficit = max(0.0, base - state[key])
122
+ surplus = max(0.0, state[key] - base)
123
+ rows[key] = {"value": state[key],
124
+ "deficit": round(deficit, 4),
125
+ "surplus": round(surplus, 4),
126
+ "bias": round(surplus - deficit, 4)}
127
+ return rows
128
+
129
+
130
+ def behavior_bias(needs, baseline=BASELINE):
131
+ """Action inclinations, each read from exactly ONE need (no weighted mix).
132
+
133
+ Before this change self_assert summed self_esteem and control, and
134
+ ask_clarify summed control and meaningful_existence; a deficit in one need
135
+ could therefore masquerade as a deficit in another.
136
+ """
137
+ rows = need_biases(needs, baseline)
115
138
 
116
139
  def deficit(key):
117
- return max(0.0, base - state[key])
140
+ return _clamp(rows.get(key, {}).get("deficit"), 0.0, 1.0)
118
141
 
119
142
  def surplus(key):
120
- return max(0.0, state[key] - base)
143
+ return _clamp(rows.get(key, {}).get("surplus"), 0.0, 1.0)
121
144
 
122
145
  return {
123
146
  "seek_contact": round(_clamp(3.0 * deficit("belonging"), 0.0, 1.0), 3),
124
- "self_assert": round(_clamp(2.0 * surplus("self_esteem")
125
- + 1.5 * deficit("control"), 0.0, 1.0), 3),
126
- "ask_clarify": round(_clamp(2.0 * deficit("control")
127
- + 1.0 * deficit("meaningful_existence"),
128
- 0.0, 1.0), 3),
147
+ "self_assert": round(_clamp(2.0 * surplus("self_esteem"), 0.0, 1.0), 3),
148
+ "ask_clarify": round(_clamp(2.0 * deficit("control"), 0.0, 1.0), 3),
129
149
  "share_meaning": round(_clamp(2.5 * surplus("meaningful_existence"),
130
150
  0.0, 1.0), 3),
131
151
  }
132
152
 
133
153
 
134
154
  def summary(needs):
135
- """Bounded row for logs/panel."""
155
+ """Bounded row for logs/panel: the four actions plus every need line."""
136
156
  state = needs_state(needs)
137
- return {"needs": state, "behavior": behavior_bias(state)}
157
+ return {"needs": state, "behavior": behavior_bias(state),
158
+ "bias": need_biases(state)}
@@ -228,6 +228,11 @@ def profile(strategy, state=None, event=None, relationship=None):
228
228
  "cost": round(cost, 3),
229
229
  "effect": round(effect, 3),
230
230
  "score": round(score, 3),
231
+ # never a ratio: effect and cost keep their own numbers, the score is
232
+ # only the display order the caller sorts by
233
+ "score_parts": {"fit": round(fit, 3), "effect": round(effect, 3),
234
+ "cost": round(cost, 3)},
235
+ "score_is_display": True,
231
236
  "coping_tier": TIER_OF[strategy],
232
237
  "reason": REASONS[strategy],
233
238
  "self_talk": SELF_TALK[strategy],
@@ -30,6 +30,7 @@ STAGES = ("cold", "state", "record", "turn_page")
30
30
  LABELS = {"cold": "冷处理", "state": "明说", "record": "记账",
31
31
  "turn_page": "翻页"}
32
32
 
33
+ # [engineering] stage durations and the forgive floor are hand-tuned.
33
34
  COLD_SECONDS = 300.0 # cold -> state
34
35
  STATE_SECONDS = 1800.0 # state -> record
35
36
  RECORD_SECONDS = 7200.0 # record -> turn_page
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  BASELINE = 0.55
9
9
  REGRESS = 0.05
10
10
  EVENT_DELTAS = {"praised": 0.06, "replied": 0.06, "ignored": -0.05, "insulted": -0.05}
11
+ EVENT_KEEP = 24
11
12
 
12
13
 
13
14
  def clamp(value):
@@ -22,9 +23,16 @@ def initial(now=0.0):
22
23
  def update(state, event_type, now):
23
24
  data = dict(state or initial(now))
24
25
  delta = EVENT_DELTAS.get(str(event_type or ""), 0.0)
25
- data["value"] = round(clamp(float(data.get("value", BASELINE)) + delta), 4)
26
+ before = clamp(float(data.get("value", BASELINE)))
27
+ data["value"] = round(clamp(before + delta), 4)
26
28
  data["last_event"] = str(event_type or "")
27
29
  data["last_event_ts"] = float(now)
30
+ # every event keeps its own row; "value" is only the running aggregate
31
+ events = [row for row in (data.get("events") or []) if isinstance(row, dict)]
32
+ events.append({"event": str(event_type or ""), "delta": round(delta, 4),
33
+ "before": round(before, 4), "after": data["value"],
34
+ "ts": float(now)})
35
+ data["events"] = events[-EVENT_KEEP:]
28
36
  return data
29
37
 
30
38
 
@@ -46,5 +54,9 @@ def confidence_delta(value):
46
54
  def view(state):
47
55
  data = dict(state or initial())
48
56
  value = round(clamp(float(data.get("value", BASELINE))), 3)
49
- return {"value": value, "baseline": BASELINE, "last_event": str(data.get("last_event") or ""),
57
+ return {"value": value, "baseline": BASELINE,
58
+ "last_event": str(data.get("last_event") or ""),
59
+ "events": [dict(row) for row in (data.get("events") or [])
60
+ if isinstance(row, dict)],
61
+ "value_is_aggregate": True,
50
62
  "confidence_delta": confidence_delta(value)}
@@ -26,8 +26,10 @@ from __future__ import annotations
26
26
  import math
27
27
 
28
28
  NEED_KEYS = ("belonging", "self_esteem", "control", "meaningful_existence")
29
- NEED_WEIGHTS = {"belonging": 0.30, "self_esteem": 0.25,
30
- "control": 0.20, "meaningful_existence": 0.25}
29
+ # Deci & Ryan 2000, Psychological Inquiry 11(4):227-268: the basic needs have
30
+ # equal standing, so every slot weighs the same instead of .30/.25/.20/.25.
31
+ NEED_WEIGHTS = {"belonging": 0.25, "self_esteem": 0.25,
32
+ "control": 0.25, "meaningful_existence": 0.25}
31
33
  KINDS = ("要你陪", "自己待着", "拒绝话题", "顺着你", "说真话")
32
34
 
33
35
  # Expectations start near the "just fine" line and drift with experience.
@@ -40,8 +42,8 @@ DEFAULT_EXPECTED = {
40
42
  EXPECTED_RATE = 0.03 # per-call drift, never a jump
41
43
  SELF_WEIGHT_FLOOR = 0.10
42
44
  SELF_WEIGHT_CEIL = 0.90
43
- MIN_WEIGHT = 0.15 # intents below this are dropped
44
- MAX_INTENTS = 2
45
+ MIN_WEIGHT = 0.15 # [engineering] intents below this are dropped
46
+ MAX_INTENTS = 2 # [engineering]
45
47
  MAX_CANDIDATES = 4
46
48
  SELF_HANDLE_URGE = 0.35 # below this she tends to handle it herself
47
49
  OPEN_UP_URGE = 0.50 # above this she tends to ask / speak up