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,210 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-230: indicative priority driven by surprise (predictive processing).
3
+
4
+ Friston 2010 (free energy): a signal that departs from the expectation carries
5
+ more information, so it should be encoded and consolidated before the rest.
6
+
7
+ Surprise
8
+ |field - setpoint| / scale(axis), per axis, clamped to 0..1. The setpoint
9
+ is the neutral line the field itself declares (field.DEFAULTS), the scale is
10
+ that axis' own span, so the six axes stay comparable without being summed
11
+ into one state number.
12
+
13
+ Priority (per candidate)
14
+ score = surprise x importance x relation x predictable
15
+
16
+ surprise how far this candidate's stored affect sits from the current
17
+ expectation (the live field)
18
+ importance the entry's own importance (memory layer)
19
+ relation the relationship context at read time (trust / closeness)
20
+ predictable repeated content with no change is damped (0.3 .. 1.0), so
21
+ the tenth identical message is not what gets rehearsed
22
+
23
+ The switch surprise_priority is ON by default; with it off the caller keeps the
24
+ old newest-first ordering and the recall scores are untouched.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import math
29
+
30
+ try:
31
+ from . import field as _field
32
+ except Exception: # pragma: no cover
33
+ try:
34
+ from biocore_runtime import field as _field
35
+ except Exception:
36
+ _field = None
37
+
38
+ SWITCH_KEY = "surprise_priority"
39
+ ENV_KEY = "BIOCORE_SURPRISE_PRIORITY"
40
+ SCHEMA = 1
41
+ AXES = ("v", "a", "stress", "social", "trust", "closeness")
42
+ SURPRISE_FLOOR = 0.35 # below this the field is "as expected"
43
+ PREDICTABLE_FLOOR = 0.3 # the lowest multiplier repeated content can reach
44
+ RANK_LIMIT = 10
45
+
46
+ RANGES = (dict(_field.RANGES) if _field is not None else
47
+ {"v": (-1.0, 1.0), "a": (0.0, 1.0), "stress": (0.0, 1.0),
48
+ "social": (0.0, 1.0), "trust": (0.0, 1.0), "closeness": (0.0, 1.0)})
49
+ SETPOINTS = (dict(_field.DEFAULTS) if _field is not None else
50
+ {"v": 0.0, "a": 0.5, "stress": 0.5, "social": 0.5,
51
+ "trust": 0.5, "closeness": 0.5})
52
+
53
+
54
+ def _num(value, default=0.0):
55
+ try:
56
+ number = float(value)
57
+ except Exception:
58
+ return float(default)
59
+ if not math.isfinite(number):
60
+ return float(default)
61
+ return number
62
+
63
+
64
+ def _clamp(value, low=0.0, high=1.0):
65
+ return max(low, min(high, _num(value, low)))
66
+
67
+
68
+ def enabled(flags=None, env=None):
69
+ """surprise_priority: explicit flag > nested switches > env > default ON."""
70
+ data = flags if isinstance(flags, dict) else {}
71
+ if isinstance(data.get(SWITCH_KEY), bool):
72
+ return data[SWITCH_KEY]
73
+ switches = data.get("switches") if isinstance(data.get("switches"), dict) else {}
74
+ if isinstance(switches.get(SWITCH_KEY), bool):
75
+ return switches[SWITCH_KEY]
76
+ environ = env if hasattr(env, "get") else None
77
+ if environ is not None and ENV_KEY in environ:
78
+ raw = str(environ.get(ENV_KEY) or "").strip().lower()
79
+ if raw in ("0", "false", "off", "no"):
80
+ return False
81
+ return True
82
+
83
+
84
+ def scale_of(axis):
85
+ low, high = RANGES.get(axis, (-1.0, 1.0))
86
+ return max(1e-6, high - low)
87
+
88
+
89
+ def axis_surprise(axis, value):
90
+ """|value - setpoint| / scale, clamped to 0..1."""
91
+ setpoint = _num(SETPOINTS.get(axis, 0.0))
92
+ return round(_clamp(abs(_num(value, setpoint) - setpoint)
93
+ / scale_of(axis)), 6)
94
+
95
+
96
+ def field_values(core=None, relationship=None):
97
+ """The six field numbers from a state's core plus the relationship."""
98
+ core = core if isinstance(core, dict) else {}
99
+ rel = relationship if isinstance(relationship, dict) else {}
100
+ out = {}
101
+ for axis in AXES:
102
+ if axis in ("trust", "closeness"):
103
+ out[axis] = rel.get(axis, core.get(axis, SETPOINTS[axis]))
104
+ else:
105
+ out[axis] = core.get(axis, SETPOINTS[axis])
106
+ return {axis: _num(value, SETPOINTS[axis]) for axis, value in out.items()}
107
+
108
+
109
+ def field_surprise(values=None):
110
+ """Per-axis surprise plus the mean; axes are never summed into a state."""
111
+ data = values if isinstance(values, dict) else {}
112
+ axes = {axis: axis_surprise(axis, data.get(axis, SETPOINTS[axis]))
113
+ for axis in AXES}
114
+ top = sorted(axes.items(), key=lambda row: -row[1])[:3]
115
+ return {"axes": axes,
116
+ "value": round(sum(axes.values()) / max(1, len(axes)), 4),
117
+ "top": [{"axis": axis, "surprise": value} for axis, value in top]}
118
+
119
+
120
+ def entry_surprise(entry, values=None):
121
+ """How unexpected this entry's own affect is against the live field."""
122
+ entry = entry if isinstance(entry, dict) else {}
123
+ field = values if isinstance(values, dict) else {}
124
+ affect = entry.get("affect") if isinstance(entry.get("affect"), dict) else {}
125
+ pairs = []
126
+ for axis in ("v", "a"):
127
+ raw = affect.get(axis, entry.get(axis))
128
+ if raw is None:
129
+ continue
130
+ expected = field.get(axis, SETPOINTS[axis])
131
+ pairs.append(_clamp(abs(_num(raw, expected) - _num(expected))
132
+ / scale_of(axis)))
133
+ if not pairs:
134
+ return 0.0
135
+ return round(sum(pairs) / len(pairs), 6)
136
+
137
+
138
+ def predictable(entry):
139
+ """Repeated, unchanged content is less worth rehearsing (1.0 = not damped)."""
140
+ entry = entry if isinstance(entry, dict) else {}
141
+ hits = max(1, int(_num(entry.get("hits"), 1)))
142
+ recalls = max(0, int(_num(entry.get("retrieval_count"), 0)))
143
+ repeat = min(1.0, (hits - 1) * 0.25 + recalls * 0.10)
144
+ if repeat <= 0:
145
+ return 1.0
146
+ return round(max(PREDICTABLE_FLOOR, 1.0 - 0.7 * repeat), 4)
147
+
148
+
149
+ def relation_factor(relationship=None):
150
+ """Relationship context in 0.25..1.0 (trust/closeness, not a sum of axes)."""
151
+ rel = relationship if isinstance(relationship, dict) else {}
152
+ trust = _clamp(rel.get("trust", 0.5))
153
+ closeness = _clamp(rel.get("closeness", 0.5))
154
+ return round(_clamp(0.25 + 0.5 * closeness + 0.25 * trust), 4)
155
+
156
+
157
+ def score_entry(entry, values=None, relationship=None):
158
+ """One candidate line: every factor kept, plus the product."""
159
+ entry = entry if isinstance(entry, dict) else {}
160
+ surprise = entry_surprise(entry, values)
161
+ importance = _clamp(entry.get("importance", 0.5))
162
+ relation = relation_factor(relationship)
163
+ damp = predictable(entry)
164
+ return {"id": str(entry.get("id") or ""),
165
+ "text": str(entry.get("text") or "")[:60],
166
+ "ts": round(_num(entry.get("ts"), 0.0), 3),
167
+ "surprise": surprise,
168
+ "importance": round(importance, 4),
169
+ "relation": relation,
170
+ "predictable": damp,
171
+ "score": round(surprise * importance * relation * damp, 6)}
172
+
173
+
174
+ def rank(entries, values=None, relationship=None, limit=RANK_LIMIT,
175
+ enabled_override=None):
176
+ """Top candidates by indicative priority (or the old newest-first order)."""
177
+ rows = [row for row in (entries or []) if isinstance(row, dict)]
178
+ if enabled_override is False:
179
+ old = sorted(rows, key=lambda row: -_num(row.get("ts"), 0.0))
180
+ return [{"id": str(row.get("id") or ""),
181
+ "text": str(row.get("text") or "")[:60],
182
+ "ts": round(_num(row.get("ts"), 0.0), 3),
183
+ "score": 0.0, "legacy": True}
184
+ for row in old[:max(1, int(limit))]]
185
+ scored = [score_entry(row, values, relationship) for row in rows]
186
+ scored.sort(key=lambda row: (-row["score"], -row["ts"]))
187
+ return scored[:max(1, int(limit))]
188
+
189
+
190
+ def recall_weight(entry, values=None):
191
+ """Multiplier for one recall candidate (1.0 when nothing is surprising).
192
+
193
+ Only content that is itself far from the current expectation is lifted, so
194
+ a surprising field does not boost every memory equally.
195
+ """
196
+ surprise = entry_surprise(entry, values)
197
+ if surprise < SURPRISE_FLOOR:
198
+ return 1.0
199
+ return round(min(2.0, 1.0 + 0.6 * (surprise - SURPRISE_FLOOR)), 4)
200
+
201
+
202
+ def summary(entries, values=None, relationship=None, limit=RANK_LIMIT,
203
+ enabled_override=None):
204
+ """Read-only /state block: the top rows plus the live field surprise."""
205
+ on = True if enabled_override is None else bool(enabled_override)
206
+ field_row = field_surprise(values)
207
+ return {"schema": SCHEMA, "enabled": on,
208
+ "field_surprise": field_row,
209
+ "top": rank(entries, values, relationship, limit,
210
+ enabled_override=on)}
@@ -0,0 +1,352 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-62: retrieval enhancement -- normalized channels, chunks, shards,
3
+ predictive exploration.
4
+
5
+ Pipeline-level retriever used after the memory engine returns candidates:
6
+ bm25 lexical term overlap with idf-like weighting
7
+ vector hashed bag-of-tokens cosine
8
+ graph query-entity co-occurrence neighbours
9
+ time recency decay
10
+ Each channel is min-max normalized independently, then fused. Sentences are
11
+ chunked (indexed per entry) and the best chunk provides the returned snippet.
12
+ Entries are assigned to physical shards (session or month) with a per-shard
13
+ cap and rolling suffix. Recent queries form a topic "expectation": entries far
14
+ from it get a small exploration boost, highly expected ones a redundancy
15
+ penalty.
16
+
17
+ Default on; the caller passes enabled=False to keep the legacy order.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import math
24
+ import re
25
+ import time
26
+ from pathlib import Path
27
+
28
+ CHANNELS = ("bm25", "vector", "graph", "time")
29
+ SHARD_LIMIT = 200
30
+ VECTOR_DIM = 64
31
+ EXPECTATION_TURNS = 5
32
+ EXPLORE_ALPHA = 0.6
33
+ DEBUG_FILE = "recall_debug.json"
34
+ TOPIC_FILE = "topic_history.json"
35
+ SHARD_FILE = "retrieval/shards.json"
36
+ CHUNK_FILE = "retrieval/chunks.json"
37
+ SCHEMA = 1
38
+
39
+ _WORD = re.compile(r"[a-z0-9_]{2,}")
40
+ _CJK = re.compile(r"[\u4e00-\u9fff]")
41
+ _SENT = re.compile(r"[^。!?!?;;\n]+[。!?!?;;]?")
42
+
43
+
44
+ def _root(store):
45
+ if isinstance(store, dict):
46
+ raw = store.get("root") or store.get("store") or store.get("path")
47
+ else:
48
+ raw = store
49
+ text = str(raw or "").strip()
50
+ return Path(text) if text else None
51
+
52
+
53
+ def _data_dir(store):
54
+ root = _root(store)
55
+ return (root.parent if root.name == "memory" else root) if root else None
56
+
57
+
58
+ def _read(path):
59
+ try:
60
+ if path is None or not path.exists():
61
+ return {}
62
+ data = json.loads(path.read_text(encoding="utf-8"))
63
+ return data if isinstance(data, dict) else {}
64
+ except Exception:
65
+ return {}
66
+
67
+
68
+ def _write(path, doc):
69
+ try:
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ tmp = path.with_name(path.name + ".tmp")
72
+ tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2),
73
+ encoding="utf-8")
74
+ tmp.replace(path)
75
+ return True
76
+ except Exception:
77
+ return False
78
+
79
+
80
+ def _num(value, default=0.0):
81
+ try:
82
+ number = float(value)
83
+ except Exception:
84
+ return default
85
+ return number if number == number else default
86
+
87
+
88
+ def tokens(text):
89
+ lowered = str(text or "").lower()
90
+ out = _WORD.findall(lowered)
91
+ for run in re.findall(r"[\u4e00-\u9fff]+", lowered):
92
+ out.extend(run[i:i + 2] for i in range(len(run) - 1))
93
+ if len(run) <= 2:
94
+ out.append(run)
95
+ return out
96
+
97
+
98
+ def vector(text, dim=VECTOR_DIM):
99
+ counts = {}
100
+ for token in tokens(text):
101
+ bucket = int(hashlib.sha1(token.encode("utf-8")).hexdigest()[:8], 16) % dim
102
+ counts[bucket] = counts.get(bucket, 0) + 1
103
+ norm = math.sqrt(sum(value * value for value in counts.values())) or 1.0
104
+ return {bucket: value / norm for bucket, value in counts.items()}
105
+
106
+
107
+ def cosine(left, right):
108
+ if not left or not right:
109
+ return 0.0
110
+ if len(left) > len(right):
111
+ left, right = right, left
112
+ return sum(value * right.get(bucket, 0.0) for bucket, value in left.items())
113
+
114
+
115
+ def normalize(scores):
116
+ """Min-max each channel independently; flat all-equal -> 1.0 or 0.0."""
117
+ values = [float(value) for value in scores]
118
+ if not values:
119
+ return []
120
+ low, high = min(values), max(values)
121
+ if high - low <= 1e-12:
122
+ return [1.0 if high > 0 else 0.0 for _ in values]
123
+ return [(value - low) / (high - low) for value in values]
124
+
125
+
126
+ def split_chunks(text, max_chars=120):
127
+ pieces = [piece.strip() for piece in _SENT.findall(str(text or ""))
128
+ if piece.strip()]
129
+ chunks = []
130
+ for piece in pieces:
131
+ if len(piece) <= max_chars:
132
+ chunks.append(piece)
133
+ continue
134
+ for start in range(0, len(piece), max_chars):
135
+ part = piece[start:start + max_chars].strip()
136
+ if part:
137
+ chunks.append(part)
138
+ return chunks or ([str(text or "").strip()] if str(text or "").strip() else [])
139
+
140
+
141
+ def chunk_index(store):
142
+ return _read((_data_dir(store) or Path(".")) / CHUNK_FILE)
143
+
144
+
145
+ def build_chunks(store, entries):
146
+ """Persist a sentence index per entry; returns id -> chunks."""
147
+ doc = chunk_index(store)
148
+ mapping = doc.get("entries") if isinstance(doc.get("entries"), dict) else {}
149
+ for entry in entries or []:
150
+ entry_id = str((entry or {}).get("id") or "")
151
+ text = str((entry or {}).get("text") or "")
152
+ if not entry_id or not text:
153
+ continue
154
+ digest = hashlib.sha1(text.encode("utf-8")).hexdigest()[:12]
155
+ current = mapping.get(entry_id)
156
+ if isinstance(current, dict) and current.get("hash") == digest:
157
+ continue
158
+ mapping[entry_id] = {"hash": digest, "chunks": split_chunks(text)}
159
+ doc.update({"schema": SCHEMA, "entries": mapping,
160
+ "updated_at": round(time.time(), 3)})
161
+ _write((_data_dir(store) or Path(".")) / CHUNK_FILE, doc)
162
+ return {key: value.get("chunks") or [] for key, value in mapping.items()}
163
+
164
+
165
+ def _best_chunk(chunks, query_tokens):
166
+ best = ""
167
+ best_hits = -1
168
+ for chunk in chunks:
169
+ lowered = set(tokens(chunk))
170
+ hits = len(lowered & set(query_tokens))
171
+ if hits > best_hits:
172
+ best, best_hits = chunk, hits
173
+ return best, max(0, best_hits)
174
+
175
+
176
+ def _shard_base(entry, session_id):
177
+ session = str(session_id or entry.get("session_id") or "")
178
+ if session:
179
+ return "s-" + re.sub(r"[^A-Za-z0-9_-]", "", session)[:24]
180
+ ts = _num(entry.get("ts"), 0.0)
181
+ return time.strftime("%Y-%m", time.localtime(ts or time.time()))
182
+
183
+
184
+ def assign_shards(store, entries, session_id="", limit=SHARD_LIMIT):
185
+ """Session/month shard with rolling suffix when a shard exceeds the cap."""
186
+ doc = _read((_data_dir(store) or Path(".")) / SHARD_FILE)
187
+ by_id = doc.get("by_id") if isinstance(doc.get("by_id"), dict) else {}
188
+ for entry in entries or []:
189
+ entry_id = str((entry or {}).get("id") or "")
190
+ if not entry_id:
191
+ continue
192
+ base = _shard_base(entry, session_id)
193
+ index = sum(1 for value in by_id.values()
194
+ if str((value or {}).get("base")) == base)
195
+ shard = base if index < limit else base + "-r%d" % (index // limit)
196
+ by_id[entry_id] = {"base": base, "shard": shard,
197
+ "ts": round(time.time(), 3)}
198
+ doc.update({"schema": SCHEMA, "by_id": by_id,
199
+ "updated_at": round(time.time(), 3)})
200
+ data_dir = _data_dir(store) or Path(".")
201
+ _write(data_dir / SHARD_FILE, doc)
202
+ # physical shard files: one JSON per shard with its entry ids
203
+ grouped = {}
204
+ for entry_id, value in by_id.items():
205
+ grouped.setdefault(str(value.get("shard") or ""), []).append(entry_id)
206
+ for name, ids in grouped.items():
207
+ _write(data_dir / "retrieval" / "shards" / (name + ".json"),
208
+ {"schema": SCHEMA, "shard": name, "ids": ids[-200:],
209
+ "updated_at": round(time.time(), 3)})
210
+ return {key: value.get("shard") for key, value in by_id.items()}
211
+
212
+
213
+ def record_query(store, query, now=None):
214
+ path = (_data_dir(store) or Path(".")) / TOPIC_FILE
215
+ doc = _read(path)
216
+ rows = [row for row in (doc.get("turns") or []) if isinstance(row, dict)]
217
+ rows.append({"ts": round(float(now or time.time()), 3),
218
+ "vector": vector(query)})
219
+ doc.update({"schema": SCHEMA, "turns": rows[-20:]})
220
+ _write(path, doc)
221
+ return len(rows[-20:])
222
+
223
+
224
+ def expectation(store):
225
+ doc = _read((_data_dir(store) or Path(".")) / TOPIC_FILE)
226
+ rows = [row for row in (doc.get("turns") or [])
227
+ if isinstance(row.get("vector"), dict)][-EXPECTATION_TURNS:]
228
+ if not rows:
229
+ return {}
230
+ total = {}
231
+ for row in rows:
232
+ for bucket, value in row["vector"].items():
233
+ try:
234
+ bucket = int(bucket)
235
+ except Exception:
236
+ continue
237
+ total[bucket] = total.get(bucket, 0.0) + _num(value)
238
+ norm = math.sqrt(sum(value * value for value in total.values())) or 1.0
239
+ return {bucket: value / norm for bucket, value in total.items()}
240
+
241
+
242
+ def _bm25_score(query_tokens, text, corpus_size, df):
243
+ body = tokens(text)
244
+ if not body:
245
+ return 0.0
246
+ score = 0.0
247
+ for token in set(query_tokens):
248
+ tf = body.count(token)
249
+ if not tf:
250
+ continue
251
+ idf = math.log(1.0 + (corpus_size + 1) / (df.get(token, 1) + 0.5))
252
+ score += idf * tf / (tf + 1.2)
253
+ return score
254
+
255
+
256
+ def _graph_score(query_tokens, entry, neighbours):
257
+ body = set(tokens(entry.get("text")))
258
+ score = 0.0
259
+ for token in query_tokens:
260
+ for other in neighbours.get(token, ()):
261
+ if other != entry.get("id") and other in neighbours.get(
262
+ "__entry_text__", {}):
263
+ pass
264
+ # co-occurrence: count query-token neighbours that also contain entry tokens
265
+ for token in query_tokens:
266
+ for other_id in neighbours.get(token, ()):
267
+ if other_id == entry.get("id"):
268
+ continue
269
+ other_text = neighbours.get("__text__", {}).get(other_id, "")
270
+ if set(tokens(other_text)) & body:
271
+ score += 1.0
272
+ return score
273
+
274
+
275
+ def rank(store, session_id, query, entries, now=None, limit=24,
276
+ enabled=True, alpha=EXPLORE_ALPHA):
277
+ """Return ranked rows with channel scores, chunk snippet, shard, reason."""
278
+ rows = [dict(entry) for entry in (entries or []) if isinstance(entry, dict)]
279
+ stamp = float(now or time.time())
280
+ query_tokens = tokens(query)
281
+ if not enabled or not rows:
282
+ return [{"entry": row, "score": float(row.get("score") or 0.0),
283
+ "reason": "fallback", "channels": {}, "chunk": "",
284
+ "shard": "", "chunk_hits": 0} for row in rows][:limit]
285
+ chunks_map = build_chunks(store, rows)
286
+ shard_map = assign_shards(store, rows, session_id=session_id)
287
+ corpus = len(rows)
288
+ df = {}
289
+ for row in rows:
290
+ for token in set(tokens(row.get("text"))):
291
+ df[token] = df.get(token, 0) + 1
292
+ neighbours = {"__text__": {str(row.get("id")): str(row.get("text")) for row in rows}}
293
+ for row in rows:
294
+ for token in set(tokens(row.get("text"))):
295
+ neighbours.setdefault(token, []).append(str(row.get("id")))
296
+ raw = {channel: [] for channel in CHANNELS}
297
+ for row in rows:
298
+ text = str(row.get("text") or "")
299
+ raw["bm25"].append(_bm25_score(query_tokens, text, corpus, df))
300
+ raw["vector"].append(cosine(vector(query), vector(text)))
301
+ raw["graph"].append(_graph_score(query_tokens, row, neighbours))
302
+ age_hours = max(0.0, (stamp - _num(row.get("ts"), stamp)) / 3600.0)
303
+ raw["time"].append(math.exp(-age_hours / (24.0 * 30.0)))
304
+ norm = {channel: normalize(values) for channel, values in raw.items()}
305
+ weights = {"bm25": 0.4, "vector": 0.3, "graph": 0.2, "time": 0.1}
306
+ expected = expectation(store)
307
+ scored = []
308
+ for index, row in enumerate(rows):
309
+ channels = {channel: round(norm[channel][index], 4)
310
+ for channel in CHANNELS}
311
+ base = sum(weights[channel] * channels[channel] for channel in CHANNELS)
312
+ dev = 1.0 - cosine(vector(row.get("text")), expected) if expected else 0.5
313
+ expected_weight = max(0.4, min(1.6, 1.0 + alpha * (dev - 0.5)))
314
+ chunk, hits = _best_chunk(chunks_map.get(str(row.get("id")), []),
315
+ query_tokens)
316
+ scored.append({
317
+ "entry": row,
318
+ "score": round(base * expected_weight, 4),
319
+ "base": round(base, 4),
320
+ "reason": "fused",
321
+ "channels": channels,
322
+ "expectation_weight": round(expected_weight, 4),
323
+ "chunk": chunk,
324
+ "chunk_hits": hits,
325
+ "shard": shard_map.get(str(row.get("id")), ""),
326
+ })
327
+ scored.sort(key=lambda item: item["score"], reverse=True)
328
+ return scored[:limit]
329
+
330
+
331
+ def record_debug(store, query, rows, now=None):
332
+ path = (_data_dir(store) or Path(".")) / DEBUG_FILE
333
+ payload = {
334
+ "schema": SCHEMA,
335
+ "ts": round(float(now or time.time()), 3),
336
+ "query": str(query or "")[:80],
337
+ "rows": [{"id": str(item["entry"].get("id") or ""),
338
+ "score": item["score"],
339
+ "channels": item["channels"],
340
+ "reason": item["reason"],
341
+ "chunk": str(item["chunk"])[:120],
342
+ "chunk_hits": item["chunk_hits"],
343
+ "shard": item["shard"]} for item in rows[:8]],
344
+ "expectation_turns": EXPECTATION_TURNS,
345
+ }
346
+ _write(path, payload)
347
+ return payload
348
+
349
+
350
+ def summary(store):
351
+ doc = _read((_data_dir(store) or Path(".")) / DEBUG_FILE)
352
+ return doc if doc else {"query": "", "rows": [], "ts": 0.0}