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
@@ -0,0 +1,355 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-244 step 1: shadow coordinates for memory entries (write-only).
3
+
4
+ Every entry gets a read-only "space" dict with five axes:
5
+
6
+ semantic the embedding vector (reused when the entry already has one)
7
+ with its provider / model name, plus topic / entity tags and a
8
+ text fingerprint fallback. semantic distance = 1 - cosine
9
+ (Jaccard over tags when either side has no vector)
10
+ time the entry ts, its own half-life in minutes, its age
11
+ affect v / a as they stood when the entry was written
12
+ social scope + trust / closeness at write time
13
+ activation importance / hits / retrieval_count / last_hit
14
+
15
+ Boundaries
16
+ Step 1 only adds coordinates. recall(), ranking and the memory score never
17
+ read this module, and recall keeps its old behaviour byte for byte.
18
+ The switch spatial_recall is OFF by default; with it off nothing is written.
19
+ A coordinate that cannot be built returns None and the entry is untouched.
20
+
21
+ Distances are reported PER AXIS. They are never added into one number; the
22
+ nearest list is ordered lexicographically (semantic, affect, time,
23
+ activation, social), which keeps every axis visible in the ranking.
24
+
25
+ Weights / half-lives below are [engineering] (see docs/PAPER-MAP.md).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import math
31
+ import re
32
+
33
+ SCHEMA = 1
34
+ SWITCH_KEY = "spatial_recall"
35
+ ENV_KEY = "BIOCORE_SPATIAL_RECALL"
36
+ AXES = ("semantic", "time", "affect", "social", "activation")
37
+ DISTANCE_ORDER = ("semantic", "affect", "time", "activation", "social")
38
+
39
+ VECTOR_LIMIT = 768
40
+ TOPIC_LIMIT = 8
41
+ ENTITY_LIMIT = 8
42
+ FINGERPRINT_CHARS = 12
43
+
44
+ BASE_HALF_LIFE_MINUTES = 180.0 # [engineering]
45
+ HALF_LIFE_BY_KIND = {"direct": 180.0, "recall": 120.0,
46
+ "summary": 240.0, "reflection": 360.0} # [engineering]
47
+ IMPORTANCE_SPAN = 0.5 # half-life scales 0.5x .. 1.5x
48
+ HITS_CEILING = 20.0 # [engineering] log scale for the hits axis
49
+ DEFAULT_IMPORTANCE = 0.5
50
+ DEFAULT_TRUST = 0.5
51
+ DEFAULT_CLOSENESS = 0.5
52
+
53
+ _TOKEN = re.compile(r"[a-z0-9_]+") # latin words
54
+ _CJK_RUN = re.compile(r"[\u4e00-\u9fff]+") # CJK runs, split into bigrams below
55
+ _WS = re.compile(r"\s+")
56
+
57
+
58
+ def _num(value, default=0.0):
59
+ try:
60
+ number = float(value)
61
+ except Exception:
62
+ return float(default)
63
+ if not math.isfinite(number):
64
+ return float(default)
65
+ return number
66
+
67
+
68
+ def _clamp(value, low=0.0, high=1.0):
69
+ return max(low, min(high, _num(value, low)))
70
+
71
+
72
+ def enabled(flags=None, env=None):
73
+ """spatial_recall: explicit flag > nested switches > env > default OFF."""
74
+ data = flags if isinstance(flags, dict) else {}
75
+ if isinstance(data.get(SWITCH_KEY), bool):
76
+ return data[SWITCH_KEY]
77
+ switches = data.get("switches") if isinstance(data.get("switches"), dict) else {}
78
+ if isinstance(switches.get(SWITCH_KEY), bool):
79
+ return switches[SWITCH_KEY]
80
+ environ = env if hasattr(env, "get") else None
81
+ if environ is not None and ENV_KEY in environ:
82
+ raw = str(environ.get(ENV_KEY)).strip().lower()
83
+ if raw in ("1", "true", "on", "yes"):
84
+ return True
85
+ if raw in ("0", "false", "off", "no"):
86
+ return False
87
+ return False
88
+
89
+
90
+ def tokens(text):
91
+ """Deterministic bag: latin words + CJK bigrams + single characters.
92
+
93
+ Bigrams are what the governance concept extractor produces, so a query and
94
+ an entry's topics line up when no embedding is available.
95
+ """
96
+ body = _WS.sub(" ", str(text or "")).lower()
97
+ out = _TOKEN.findall(body)
98
+ for run in _CJK_RUN.findall(body):
99
+ if len(run) == 1:
100
+ out.append(run)
101
+ continue
102
+ out.extend(run[index:index + 2] for index in range(len(run) - 1))
103
+ out.extend(run)
104
+ seen = []
105
+ for item in out:
106
+ if item not in seen:
107
+ seen.append(item)
108
+ return seen
109
+
110
+
111
+ def fingerprint(text):
112
+ body = _WS.sub(" ", str(text or "")).strip().lower()
113
+ return hashlib.sha1(body.encode("utf-8")).hexdigest()[:FINGERPRINT_CHARS]
114
+
115
+
116
+ def half_life_minutes(entry):
117
+ """Per-entry half-life: the kind's base, scaled by importance."""
118
+ entry = entry if isinstance(entry, dict) else {}
119
+ kind = str(entry.get("kind") or entry.get("memory_kind") or "direct")
120
+ base = HALF_LIFE_BY_KIND.get(kind, BASE_HALF_LIFE_MINUTES)
121
+ importance = _clamp(entry.get("importance", DEFAULT_IMPORTANCE))
122
+ return round(base * (1.0 - IMPORTANCE_SPAN + 2.0 * IMPORTANCE_SPAN * importance),
123
+ 3)
124
+
125
+
126
+ def _vector(entry):
127
+ raw = entry.get("vector") if isinstance(entry, dict) else None
128
+ if not isinstance(raw, (list, tuple)):
129
+ return []
130
+ out = []
131
+ for value in raw[:VECTOR_LIMIT]:
132
+ try:
133
+ number = float(value)
134
+ except Exception:
135
+ continue
136
+ if math.isfinite(number):
137
+ out.append(round(number, 6))
138
+ return out
139
+
140
+
141
+ def _tags(values, limit):
142
+ if isinstance(values, str):
143
+ values = [values]
144
+ if not isinstance(values, (list, tuple)):
145
+ return []
146
+ out = []
147
+ for value in values:
148
+ text = str(value or "").strip()
149
+ if text and text not in out:
150
+ out.append(text[:32])
151
+ return out[:limit] # tags first, then concepts: order is meaningful
152
+
153
+
154
+ def _embed_names(entry, embed=None):
155
+ """Provider / model of the embedding, so mismatches are visible at a glance."""
156
+ entry = entry if isinstance(entry, dict) else {}
157
+ info = embed if isinstance(embed, dict) else {}
158
+ provider = str(info.get("provider")
159
+ or entry.get("embedding_provider")
160
+ or entry.get("vector_provider")
161
+ or entry.get("embed_provider") or "")[:64]
162
+ model = str(info.get("model")
163
+ or entry.get("embedding_model")
164
+ or entry.get("vector_model")
165
+ or entry.get("embed_model") or "")[:64]
166
+ return provider, model
167
+
168
+
169
+ def build(entry, relationship=None, now=None, embed=None):
170
+ """Five-axis coordinates for one entry; None when they cannot be built."""
171
+ try:
172
+ return _build(entry, relationship, now, embed)
173
+ except Exception: # a broken entry never breaks the write
174
+ return None
175
+
176
+
177
+ def _build(entry, relationship=None, now=None, embed=None):
178
+ if not isinstance(entry, dict):
179
+ return None # nothing to put coordinates on
180
+ rel = relationship if isinstance(relationship, dict) else {}
181
+ embed_provider, embed_model = _embed_names(entry, embed)
182
+ stamp = _num(now, _num(entry.get("ts"), 0.0))
183
+ ts = round(_num(entry.get("ts"), stamp), 3)
184
+ vector = _vector(entry)
185
+ topics = _tags(list(entry.get("tags") or []) + list(entry.get("concepts") or []),
186
+ TOPIC_LIMIT)
187
+ entities = _tags(entry.get("entities") or entry.get("entity"), ENTITY_LIMIT)
188
+ affect = entry.get("affect") if isinstance(entry.get("affect"), dict) else {}
189
+ trust = rel.get("trust", entry.get("trust", DEFAULT_TRUST))
190
+ closeness = rel.get("closeness", entry.get("closeness", DEFAULT_CLOSENESS))
191
+ return {
192
+ "schema": SCHEMA,
193
+ "semantic": {
194
+ "vector": vector,
195
+ "vector_dim": len(vector),
196
+ # true = tags only; never pretend a vector exists
197
+ "fallback": not bool(vector),
198
+ "provider": embed_provider,
199
+ "model": embed_model,
200
+ "topics": topics,
201
+ "entities": entities,
202
+ "fingerprint": fingerprint(entry.get("text")),
203
+ },
204
+ "time": {
205
+ "ts": ts,
206
+ "half_life_minutes": half_life_minutes(entry),
207
+ "age_minutes": round(max(0.0, stamp - ts) / 60.0, 3),
208
+ "last_hit_ts": round(_num(entry.get("last_hit_ts"), 0.0), 3),
209
+ },
210
+ "affect": {
211
+ "v": round(_clamp(affect.get("v", entry.get("v", 0.0)), -1.0, 1.0), 6),
212
+ "a": round(_clamp(affect.get("a", entry.get("a", 0.5))), 6),
213
+ },
214
+ "social": {
215
+ "scope": str(entry.get("scope") or entry.get("session_id") or "default")[:64],
216
+ "trust": round(_clamp(trust), 6),
217
+ "closeness": round(_clamp(closeness), 6),
218
+ },
219
+ "activation": {
220
+ "importance": round(_clamp(entry.get("importance", DEFAULT_IMPORTANCE)), 6),
221
+ "hits": int(_num(entry.get("hits"), 1)),
222
+ "retrieval_count": int(_num(entry.get("retrieval_count"), 0)),
223
+ "last_hit_ts": round(_num(entry.get("last_hit_ts"), 0.0), 3),
224
+ },
225
+ }
226
+
227
+
228
+ # ------------------------------------------------------------------ distance
229
+ def _cosine_distance(x, y):
230
+ dot = sum(a * b for a, b in zip(x, y))
231
+ norm_x = math.sqrt(sum(a * a for a in x))
232
+ norm_y = math.sqrt(sum(b * b for b in y))
233
+ if norm_x <= 0 or norm_y <= 0:
234
+ return 1.0
235
+ return _clamp(1.0 - dot / (norm_x * norm_y), 0.0, 2.0)
236
+
237
+
238
+ def _semantic_distance(x, y):
239
+ x = x if isinstance(x, dict) else {}
240
+ y = y if isinstance(y, dict) else {}
241
+ vx = _vector({"vector": x.get("vector")})
242
+ vy = _vector({"vector": y.get("vector")})
243
+ if vx and vy and len(vx) == len(vy):
244
+ # 1 - cosine: 0 for the same direction, 2 for the opposite one
245
+ return round(_clamp(_cosine_distance(vx, vy), 0.0, 2.0), 6)
246
+ tx, ty = set(x.get("topics") or []), set(y.get("topics") or [])
247
+ ex, ey = set(x.get("entities") or []), set(y.get("entities") or [])
248
+ union = tx | ty | ex | ey
249
+ if not union:
250
+ return 1.0
251
+ shared = (tx & ty) | (ex & ey)
252
+ return round(1.0 - len(shared) / len(union), 6)
253
+
254
+
255
+ def _time_distance(x, y):
256
+ x = x if isinstance(x, dict) else {}
257
+ y = y if isinstance(y, dict) else {}
258
+ gap = abs(_num(x.get("ts"), 0.0) - _num(y.get("ts"), 0.0))
259
+ scale = max(1.0, _num(x.get("half_life_minutes"), BASE_HALF_LIFE_MINUTES),
260
+ _num(y.get("half_life_minutes"), BASE_HALF_LIFE_MINUTES)) * 60.0
261
+ return round(_clamp(gap / scale), 6)
262
+
263
+
264
+ def _affect_distance(x, y):
265
+ x = x if isinstance(x, dict) else {}
266
+ y = y if isinstance(y, dict) else {}
267
+ dv = _num(x.get("v"), 0.0) - _num(y.get("v"), 0.0)
268
+ da = _num(x.get("a"), 0.5) - _num(y.get("a"), 0.5)
269
+ return round(_clamp(math.sqrt((dv * dv + da * da) / 2.0)), 6)
270
+
271
+
272
+ def _social_distance(x, y):
273
+ x = x if isinstance(x, dict) else {}
274
+ y = y if isinstance(y, dict) else {}
275
+ scope = 0.0 if str(x.get("scope") or "") == str(y.get("scope") or "") else 1.0
276
+ spread = (abs(_num(x.get("trust"), DEFAULT_TRUST)
277
+ - _num(y.get("trust"), DEFAULT_TRUST))
278
+ + abs(_num(x.get("closeness"), DEFAULT_CLOSENESS)
279
+ - _num(y.get("closeness"), DEFAULT_CLOSENESS))) / 2.0
280
+ return round(_clamp(0.5 * scope + 0.5 * spread), 6)
281
+
282
+
283
+ def _activation_distance(x, y):
284
+ x = x if isinstance(x, dict) else {}
285
+ y = y if isinstance(y, dict) else {}
286
+ importance = abs(_num(x.get("importance"), DEFAULT_IMPORTANCE)
287
+ - _num(y.get("importance"), DEFAULT_IMPORTANCE))
288
+ ceiling = math.log1p(HITS_CEILING)
289
+ hits = abs(math.log1p(max(0.0, _num(x.get("hits"), 0.0)))
290
+ - math.log1p(max(0.0, _num(y.get("hits"), 0.0)))) / ceiling
291
+ return round(_clamp(0.6 * importance + 0.4 * hits), 6)
292
+
293
+
294
+ def distance(space_a, space_b):
295
+ """Per-axis distance; there is no total, on purpose."""
296
+ a = space_a if isinstance(space_a, dict) else {}
297
+ b = space_b if isinstance(space_b, dict) else {}
298
+ return {
299
+ "semantic": _semantic_distance(a.get("semantic"), b.get("semantic")),
300
+ "time": _time_distance(a.get("time"), b.get("time")),
301
+ "affect": _affect_distance(a.get("affect"), b.get("affect")),
302
+ "social": _social_distance(a.get("social"), b.get("social")),
303
+ "activation": _activation_distance(a.get("activation"),
304
+ b.get("activation")),
305
+ }
306
+
307
+
308
+ def order_key(distances):
309
+ """Lexicographic order over the axes; nothing is summed."""
310
+ row = distances if isinstance(distances, dict) else {}
311
+ return tuple(_num(row.get(axis), 1.0) for axis in DISTANCE_ORDER)
312
+
313
+
314
+ # --------------------------------------------------------------------- probe
315
+ def query_space(query, vector=None, now=None, embed=None):
316
+ """Coordinates for a bare query string (same five axes, no id)."""
317
+ return build({"text": query, "vector": vector or [],
318
+ "ts": _num(now, 0.0), "tags": tokens(query),
319
+ "importance": DEFAULT_IMPORTANCE}, now=now, embed=embed)
320
+
321
+
322
+ def probe(entries, entry_id=None, query=None, vector=None, limit=5, now=None):
323
+ """Debug read: one entry's five axes, or the nearest entries to a query."""
324
+ rows = [row for row in (entries or []) if isinstance(row, dict)]
325
+ limit = max(1, min(20, int(_num(limit, 5))))
326
+ if entry_id:
327
+ want = str(entry_id)
328
+ for row in rows:
329
+ if str(row.get("id") or "") == want:
330
+ stored = row.get("space")
331
+ space = stored if isinstance(stored, dict) else build(row, now=now)
332
+ return {"ok": space is not None, "id": want,
333
+ "source": "entry" if isinstance(stored, dict) else "computed",
334
+ "space": space,
335
+ "reason": "" if space is not None else "build_failed"}
336
+ return {"ok": False, "reason": "not_found", "id": want}
337
+ if query:
338
+ target = query_space(query, vector=vector, now=now)
339
+ scored = []
340
+ for row in rows:
341
+ stored = row.get("space")
342
+ space = stored if isinstance(stored, dict) else build(row, now=now)
343
+ if not isinstance(space, dict):
344
+ continue
345
+ distances = distance(space, target)
346
+ scored.append({"id": str(row.get("id") or ""),
347
+ "text": str(row.get("text") or "")[:80],
348
+ "ts": round(_num(row.get("ts"), 0.0), 3),
349
+ "distance": distances,
350
+ "from": "entry" if isinstance(stored, dict) else "computed"})
351
+ scored.sort(key=lambda item: order_key(item["distance"]))
352
+ return {"ok": True, "query": str(query), "count": len(scored),
353
+ "limit": limit, "order": list(DISTANCE_ORDER),
354
+ "space": target, "results": scored[:limit]}
355
+ return {"ok": False, "reason": "id_or_query_required"}
@@ -18,6 +18,7 @@ import unicodedata
18
18
  from ..core.util import clamp01
19
19
  from ..index.entities import cues
20
20
 
21
+ # [engineering] the four-level rules and these thresholds are hand-set.
21
22
  L1_MERGE = 0.90
22
23
  L1_FLAG = 0.75
23
24
  L3_WINDOW_SECONDS = 60.0
@@ -26,7 +26,7 @@ DIM_WEIGHTS = {
26
26
  "trust": 0.10,
27
27
  "stress": 0.10,
28
28
  }
29
- MASK_COST = 0.22
29
+ MASK_COST = 0.22 # [engineering] energy cost of surface acting, hand-tuned
30
30
  LOAD_DECAY_PER_HOUR = 0.03
31
31
  BREAKDOWN_THRESHOLD = 0.85
32
32
 
@@ -121,6 +121,12 @@ def update(labor_state, expressed, felt, honesty=0.0, now=0.0,
121
121
  load = _clamp01(load + cost)
122
122
  data["load"] = round(load, 4)
123
123
  data["last_cost"] = cost
124
+ # Three components stay separate: a single "cost" hid which one moved.
125
+ data["components"] = {
126
+ "emotional_exhaustion": round(load, 4),
127
+ "depersonalization": round(mask, 4),
128
+ "personal_accomplishment": round(max(0.0, 1.0 - mask), 4),
129
+ }
124
130
  data["last_masking"] = mask
125
131
  data["turns"] = int(data.get("turns", 0) or 0) + 1
126
132
  if load >= BREAKDOWN_THRESHOLD and before < BREAKDOWN_THRESHOLD:
@@ -157,6 +163,13 @@ def view(labor_state):
157
163
  "collapsed": collapsed(data),
158
164
  "last_cost": round(_clamp01(data.get("last_cost", 0.0)), 4),
159
165
  "last_masking": round(_clamp01(data.get("last_masking", 0.0)), 4),
166
+ "components": {
167
+ "emotional_exhaustion": round(_clamp01(data.get("load", 0.0)), 4),
168
+ "depersonalization": round(
169
+ _clamp01(data.get("last_masking", 0.0)), 4),
170
+ "personal_accomplishment": round(
171
+ max(0.0, 1.0 - _clamp01(data.get("last_masking", 0.0))), 4),
172
+ },
160
173
  "turns": int(data.get("turns", 0) or 0),
161
174
  "breakdowns": int(data.get("breakdowns", 0) or 0),
162
175
  }
@@ -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)