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
@@ -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
@@ -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
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import time
6
6
 
7
- TIERING_ENABLED = False
7
+ TIERING_ENABLED = True
8
8
  HOT_BUDGET = 80
9
9
  WARM_BUDGET = 200
10
10
  TIER_UNLIMITED = 10 ** 9
@@ -12,6 +12,7 @@ HOT_AGE_SECONDS = 24 * 3600
12
12
  WARM_AGE_SECONDS = 30 * 86400
13
13
  HOT_IMPORTANCE = 0.80
14
14
  HOT_HITS = 5
15
+ HOT_EXIT_HITS = 3 # hysteresis: a hot entry leaves only below this
15
16
 
16
17
 
17
18
  def _num(entry, keys, default=0.0):
@@ -33,8 +34,12 @@ def _age(entry, now):
33
34
  return max(0.0, float(now) - last)
34
35
 
35
36
 
36
- def tier_of(entry, now=None):
37
- """Return 'hot' | 'warm' | 'cold'; disabled tiering is neutral ('warm')."""
37
+ def tier_of(entry, now=None, previous=None):
38
+ """Return 'hot' | 'warm' | 'cold'; disabled tiering is neutral ('warm').
39
+
40
+ Hysteresis (task-36): a hot entry stays hot until hits drop below
41
+ HOT_EXIT_HITS, so a value hovering at the boundary cannot flicker.
42
+ """
38
43
  if not TIERING_ENABLED or not isinstance(entry, dict):
39
44
  return "warm"
40
45
  try:
@@ -42,6 +47,12 @@ def tier_of(entry, now=None):
42
47
  age = _age(entry, stamp)
43
48
  importance = _num(entry, ("importance",), 0.5)
44
49
  hits = _num(entry, ("hits", "visits", "visit_count"), 0.0)
50
+ if previous == "hot":
51
+ if hits >= HOT_EXIT_HITS:
52
+ return "hot"
53
+ if age <= WARM_AGE_SECONDS or hits >= 2:
54
+ return "warm"
55
+ return "cold"
45
56
  if age <= HOT_AGE_SECONDS or importance >= HOT_IMPORTANCE or hits >= HOT_HITS:
46
57
  return "hot"
47
58
  if age <= WARM_AGE_SECONDS or hits >= 2:
@@ -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
  }