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.
- package/lib/index.js +30 -1
- package/lib/modules/standby.js +61 -0
- package/lib/modules/state.js +22 -3
- package/package.json +1 -1
- package/python/biocore_memory/core/config.py +7 -3
- package/python/biocore_memory/engine.py +91 -6
- package/python/biocore_memory/recall/embedding.py +0 -51
- package/python/biocore_memory/recall/feedback.py +34 -7
- package/python/biocore_memory/recall/fusion.py +5 -1
- package/python/biocore_memory/scoring.py +203 -0
- package/python/biocore_memory/space.py +355 -0
- package/python/biocore_memory/store/dedupe.py +1 -0
- package/python/biocore_memory/store/tiering.py +14 -3
- package/python/biocore_model/affective_labor.py +14 -1
- package/python/biocore_model/appraisal.py +172 -3
- package/python/biocore_model/bio_tick.py +43 -13
- package/python/biocore_model/emotion_geometry.py +9 -0
- package/python/biocore_model/envy.py +21 -0
- package/python/biocore_model/feedback_memory.py +53 -0
- package/python/biocore_model/needs_events.py +32 -11
- package/python/biocore_model/regulation_strategies.py +5 -0
- package/python/biocore_model/repair_after_boundary.py +1 -0
- package/python/biocore_model/self_esteem.py +14 -2
- package/python/biocore_model/self_intent.py +6 -4
- package/python/biocore_model/tests/test_autonomy_self.py +153 -153
- package/python/biocore_model/tests/test_cache_series.py +137 -0
- package/python/biocore_model/tests/test_core_state.py +240 -240
- package/python/biocore_model/tests/test_extensions.py +167 -167
- package/python/biocore_model/tests/test_field_arbiter.py +444 -0
- package/python/biocore_model/tests/test_governance.py +181 -181
- package/python/biocore_model/tests/test_lifecycle.py +153 -0
- package/python/biocore_model/tests/test_memory_inject.py +211 -211
- package/python/biocore_model/tests/test_retrieval_plus.py +143 -0
- package/python/biocore_model/tests/test_signals.py +106 -106
- package/python/biocore_model/tests/test_standby.py +199 -0
- package/python/biocore_runtime/__init__.py +3 -0
- package/python/biocore_runtime/arbiter.py +307 -0
- package/python/biocore_runtime/batch_b.py +724 -0
- package/python/biocore_runtime/batch_c.py +186 -0
- package/python/biocore_runtime/batch_d.py +659 -0
- package/python/biocore_runtime/batch_e.py +328 -0
- package/python/biocore_runtime/batch_registry.py +446 -0
- package/python/biocore_runtime/cache_stats.py +161 -0
- package/python/biocore_runtime/core_state.py +106 -3
- package/python/biocore_runtime/dimensions.py +419 -0
- package/python/biocore_runtime/expression.py +261 -0
- package/python/biocore_runtime/field.py +440 -0
- package/python/biocore_runtime/lifecycle.py +233 -0
- package/python/biocore_runtime/perception.py +1045 -0
- package/python/biocore_runtime/pipeline.py +4458 -2974
- package/python/biocore_runtime/priority.py +210 -0
- package/python/biocore_runtime/retrieval_plus.py +352 -0
- package/python/biocore_runtime/standby.py +581 -472
- package/python/biocore_runtime/timescales.py +263 -0
- package/python/data/signals.json +3266 -206
- package/ui/panel-layout.js +32 -29
- package/ui/panel.css +563 -974
- package/ui/panel.html +427 -401
- 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"}
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import time
|
|
6
6
|
|
|
7
|
-
TIERING_ENABLED =
|
|
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
|
}
|