dsh-biocore 0.3.2 → 0.4.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/package.json +1 -1
- package/python/biocore_memory/store/tiering.py +14 -3
- package/python/biocore_model/sanitize.py +6 -1
- package/python/biocore_model/tests/test_cache_series.py +137 -0
- package/python/biocore_model/tests/test_lifecycle.py +153 -0
- package/python/biocore_model/tests/test_retrieval_plus.py +143 -0
- package/python/biocore_model/tests/test_standby.py +199 -0
- package/python/biocore_runtime/__init__.py +3 -0
- package/python/biocore_runtime/cache_stats.py +161 -0
- package/python/biocore_runtime/lifecycle.py +233 -0
- package/python/biocore_runtime/pipeline.py +134 -3
- package/python/biocore_runtime/retrieval_plus.py +352 -0
- package/python/biocore_runtime/standby.py +125 -18
- package/python/data/signals.json +3266 -206
package/lib/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import { SERIES_ROUTE_PATH, handleSeriesRoute } from './modules/series.js'
|
|
|
28
28
|
import { readCacheStats } from './modules/cache-stats.js'
|
|
29
29
|
import { startCoreWorker, stopCoreWorker } from './core-worker.js'
|
|
30
30
|
import { runAutonomyTick } from './modules/autonomy.js'
|
|
31
|
+
import { drainOutbox } from './modules/standby.js'
|
|
31
32
|
import { queueSessionTurn } from './modules/turn-queue.js'
|
|
32
33
|
import { makeCoreSessionListener, registerCoreTurn } from './modules/core-turn.js'
|
|
33
34
|
import { migrateLegacyPluginData, PLUGIN_DATA_DIR, resolveDataDir } from './modules/data-dir.js'
|
|
@@ -193,6 +194,8 @@ export function readFullConfig(dataDir) {
|
|
|
193
194
|
rerank: normalizeRerank(source.rerank),
|
|
194
195
|
expression: normalizeExpression(source.expression),
|
|
195
196
|
autonomy: normalizeAutonomy(source.autonomy),
|
|
197
|
+
standby: source.standby && typeof source.standby === 'object' ? source.standby : {},
|
|
198
|
+
standbyEnabled: source.standbyEnabled === true,
|
|
196
199
|
sanitize_reply: source.sanitize_reply !== false,
|
|
197
200
|
}
|
|
198
201
|
}
|
|
@@ -389,6 +392,10 @@ function coreConfigPayload(dataDir) {
|
|
|
389
392
|
rerank: config.rerank,
|
|
390
393
|
expression: config.expression,
|
|
391
394
|
autonomy: config.autonomy,
|
|
395
|
+
standby: {
|
|
396
|
+
...(config.standby && typeof config.standby === 'object' ? config.standby : {}),
|
|
397
|
+
standby_enabled: config.standbyEnabled === true,
|
|
398
|
+
},
|
|
392
399
|
sanitize_reply: config.sanitize_reply !== false,
|
|
393
400
|
switches,
|
|
394
401
|
recall_mode: 'query',
|
|
@@ -762,8 +769,10 @@ export async function handleStats(req, res, options) {
|
|
|
762
769
|
} catch { reply = null }
|
|
763
770
|
const body = Object.assign({ ok: false, reason: 'core_unavailable' },
|
|
764
771
|
reply && typeof reply === 'object' ? reply : {})
|
|
772
|
+
// contract migration 1: the core computes cache stats; the legacy file is a
|
|
773
|
+
// read-only fallback for one week, never a source of new numbers.
|
|
765
774
|
try {
|
|
766
|
-
if (typeof opts.dataDir === 'string' && opts.dataDir) {
|
|
775
|
+
if (!body.cache && typeof opts.dataDir === 'string' && opts.dataDir) {
|
|
767
776
|
body.cache = readCacheStats(opts.dataDir)
|
|
768
777
|
}
|
|
769
778
|
} catch { /* display only */ }
|
|
@@ -992,6 +1001,26 @@ export function apply(ctx, config) {
|
|
|
992
1001
|
keep(() => { if (timer !== null) { clearInterval(timer); timer = null } })
|
|
993
1002
|
}
|
|
994
1003
|
|
|
1004
|
+
// task-215: standby is off by default; when on, the core writes the outbox
|
|
1005
|
+
// and this adapter only drains it into results.
|
|
1006
|
+
if (config && config.standbyEnabled === true) {
|
|
1007
|
+
let standbyTimer = null
|
|
1008
|
+
const standbyTick = async () => {
|
|
1009
|
+
try {
|
|
1010
|
+
await runtimeAction('standby_tick', {
|
|
1011
|
+
store: resolveMemoryDir(dataDir),
|
|
1012
|
+
data_dir: dataDir,
|
|
1013
|
+
session_id: 'standby',
|
|
1014
|
+
now: Date.now() / 1000,
|
|
1015
|
+
})
|
|
1016
|
+
drainOutbox(dataDir)
|
|
1017
|
+
} catch { /* standby never disturbs a turn */ }
|
|
1018
|
+
}
|
|
1019
|
+
standbyTimer = setInterval(() => { void standbyTick() }, 5 * 60 * 1000)
|
|
1020
|
+
if (typeof standbyTimer.unref === 'function') standbyTimer.unref()
|
|
1021
|
+
keep(() => { if (standbyTimer !== null) { clearInterval(standbyTimer); standbyTimer = null } })
|
|
1022
|
+
}
|
|
1023
|
+
|
|
995
1024
|
return () => {
|
|
996
1025
|
for (const dispose of disposers) {
|
|
997
1026
|
try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** task-215: adapter side of standby -- read outbox, record results only.
|
|
2
|
+
*
|
|
3
|
+
* The core writes <dataDir>/proactive/outbox.jsonl. This module never decides
|
|
4
|
+
* whether to speak and never sends externally; it records one execution result
|
|
5
|
+
* per outbox row into <dataDir>/proactive/results.jsonl.
|
|
6
|
+
*/
|
|
7
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export function proactiveDir(dataDir) {
|
|
11
|
+
return join(String(dataDir || '').trim(), 'proactive')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function outboxPath(dataDir) {
|
|
15
|
+
return join(proactiveDir(dataDir), 'outbox.jsonl')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resultsPath(dataDir) {
|
|
19
|
+
return join(proactiveDir(dataDir), 'results.jsonl')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readLines(path) {
|
|
23
|
+
try {
|
|
24
|
+
if (!existsSync(path)) return []
|
|
25
|
+
return readFileSync(path, 'utf8').split('\n').filter(Boolean)
|
|
26
|
+
} catch {
|
|
27
|
+
return []
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Record never-seen outbox rows; returns how many were recorded. */
|
|
32
|
+
export function drainOutbox(dataDir, options) {
|
|
33
|
+
const opts = options || {}
|
|
34
|
+
const rows = readLines(outboxPath(dataDir))
|
|
35
|
+
const done = readLines(resultsPath(dataDir)).length
|
|
36
|
+
if (rows.length <= done) return { ok: true, recorded: 0, pending: 0 }
|
|
37
|
+
const path = resultsPath(dataDir)
|
|
38
|
+
try {
|
|
39
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
40
|
+
for (let index = done; index < rows.length; index += 1) {
|
|
41
|
+
let row = {}
|
|
42
|
+
try { row = JSON.parse(rows[index]) } catch { row = {} }
|
|
43
|
+
appendFileSync(path, JSON.stringify({
|
|
44
|
+
ts: row.ts || 0,
|
|
45
|
+
index,
|
|
46
|
+
delivered: false,
|
|
47
|
+
reason: 'outbox_only',
|
|
48
|
+
result: { ok: true, external: false },
|
|
49
|
+
}) + '\n', 'utf8')
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, recorded: rows.length - done, pending: 0 }
|
|
52
|
+
} catch {
|
|
53
|
+
return { ok: false, recorded: 0, pending: rows.length - done }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function standbySummary(dataDir) {
|
|
58
|
+
const outbox = readLines(outboxPath(dataDir)).length
|
|
59
|
+
const results = readLines(resultsPath(dataDir)).length
|
|
60
|
+
return { pending: Math.max(0, outbox - results), outbox, results }
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -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:
|
|
@@ -23,7 +23,12 @@ class ReplySanitizer:
|
|
|
23
23
|
|
|
24
24
|
CONTROL_TAG_PATTERNS = [
|
|
25
25
|
re.compile(r"<\s*(?:bc|biocore)\b[^>]*>.*?</\s*(?:bc|biocore)\s*>", re.I | re.S),
|
|
26
|
-
re.compile(r"<\s*(?:bc|biocore)\b[^>]*>[^\n]*", re.I),
|
|
26
|
+
re.compile(r"<\s*(?:bc|biocore)\b[^>]*>[^\n]*", re.I),
|
|
27
|
+
# Reminder tags are instructions for the model, never user-visible text.
|
|
28
|
+
re.compile(r"<\s*(?:system_reminder|reminder|system)\b[^>]*>.*?</\s*(?:system_reminder|reminder|system)\s*>", re.I | re.S),
|
|
29
|
+
re.compile(r"<\s*(?:system_reminder|reminder|system)\b[^>]*>[^\n]*", re.I),
|
|
30
|
+
# Models sometimes echo the instruction header verbatim.
|
|
31
|
+
re.compile(r"^\s*【[^】]*】\s*$", re.M),
|
|
27
32
|
re.compile(r"<\s*(?:thinking|think|thought|reasoning|analysis)\b[^>]*>.*?</\s*(?:thinking|think|thought|reasoning|analysis)\s*>", re.I | re.S),
|
|
28
33
|
re.compile(r"<\s*(?:thinking|think|thought|reasoning|analysis)\b[^>]*>[^\n]*", re.I),
|
|
29
34
|
re.compile(r"<\s*(?:思考|分析|推理|内心独白)\s*>.*?</\s*(?:思考|分析|推理|内心独白)\s*>", re.S),
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Contract migration 1: cache stats + series are computed by the core."""
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from biocore_runtime import cache_stats, core_state, pipeline
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CacheCase(unittest.TestCase):
|
|
13
|
+
def setUp(self):
|
|
14
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
15
|
+
self.data_dir = Path(self.tmp.name) / "data"
|
|
16
|
+
self.store = self.data_dir / "memory"
|
|
17
|
+
(self.store / "sessions").mkdir(parents=True)
|
|
18
|
+
|
|
19
|
+
def tearDown(self):
|
|
20
|
+
self.tmp.cleanup()
|
|
21
|
+
|
|
22
|
+
def event(self, **values):
|
|
23
|
+
row = {"prompt_tokens": 100, "output_tokens": 20,
|
|
24
|
+
"cache_read": 60, "cache_write": 40}
|
|
25
|
+
row.update(values)
|
|
26
|
+
return cache_stats.record_event(self.store, row,
|
|
27
|
+
now=values.pop("ts", time.time()))
|
|
28
|
+
|
|
29
|
+
def test_accumulates_events(self):
|
|
30
|
+
now = time.time()
|
|
31
|
+
cache_stats.record_event(self.store, {"prompt_tokens": 10,
|
|
32
|
+
"output_tokens": 2, "cache_read": 4, "cache_write": 1}, now=now)
|
|
33
|
+
cache_stats.record_event(self.store, {"prompt_tokens": 20,
|
|
34
|
+
"output_tokens": 3, "cache_read": 6, "cache_write": 2}, now=now)
|
|
35
|
+
out = cache_stats.compute(self.store, now=now)
|
|
36
|
+
self.assertEqual(out["turns"], 2)
|
|
37
|
+
self.assertEqual(out["prompt_tokens"], 30)
|
|
38
|
+
self.assertEqual(out["output_tokens"], 5)
|
|
39
|
+
self.assertEqual(out["cache_read"], 10)
|
|
40
|
+
self.assertEqual(out["cache_write"], 3)
|
|
41
|
+
self.assertEqual(out["source"], "usage_events")
|
|
42
|
+
|
|
43
|
+
def test_hit_rate_ratio(self):
|
|
44
|
+
now = time.time()
|
|
45
|
+
cache_stats.record_event(self.store, {"prompt_tokens": 100,
|
|
46
|
+
"output_tokens": 10, "cache_read": 75, "cache_write": 25}, now=now)
|
|
47
|
+
self.assertAlmostEqual(cache_stats.compute(self.store, now=now)["hit_rate"],
|
|
48
|
+
0.75, places=4)
|
|
49
|
+
|
|
50
|
+
def test_hit_rate_falls_back_to_prompt_tokens(self):
|
|
51
|
+
now = time.time()
|
|
52
|
+
cache_stats.record_event(self.store, {"prompt_tokens": 100,
|
|
53
|
+
"output_tokens": 10, "cache_read": 50, "cache_write": 0}, now=now)
|
|
54
|
+
self.assertAlmostEqual(cache_stats.compute(self.store, now=now)["hit_rate"],
|
|
55
|
+
0.5, places=4)
|
|
56
|
+
|
|
57
|
+
def test_hit_rate_zero_when_empty(self):
|
|
58
|
+
self.assertEqual(cache_stats.compute(self.store)["hit_rate"], 0.0)
|
|
59
|
+
|
|
60
|
+
def test_hit_rate_is_clamped(self):
|
|
61
|
+
now = time.time()
|
|
62
|
+
cache_stats.record_event(self.store, {"prompt_tokens": 10,
|
|
63
|
+
"output_tokens": 1, "cache_read": 50, "cache_write": 0}, now=now)
|
|
64
|
+
rate = cache_stats.compute(self.store, now=now)["hit_rate"]
|
|
65
|
+
self.assertGreaterEqual(rate, 0.0)
|
|
66
|
+
self.assertLessEqual(rate, 1.0)
|
|
67
|
+
|
|
68
|
+
def test_window_excludes_old_events(self):
|
|
69
|
+
now = time.time()
|
|
70
|
+
cache_stats.record_event(self.store, {"prompt_tokens": 10,
|
|
71
|
+
"cache_read": 5}, now=now - 8 * 86400)
|
|
72
|
+
cache_stats.record_event(self.store, {"prompt_tokens": 20,
|
|
73
|
+
"cache_read": 10}, now=now)
|
|
74
|
+
out = cache_stats.compute(self.store, now=now)
|
|
75
|
+
self.assertEqual(out["turns"], 1)
|
|
76
|
+
self.assertEqual(out["prompt_tokens"], 20)
|
|
77
|
+
|
|
78
|
+
def test_legacy_file_is_read_only_fallback(self):
|
|
79
|
+
now = time.time()
|
|
80
|
+
(self.data_dir / "cache_stats.json").write_text(json.dumps({
|
|
81
|
+
"turns": 4, "prompt_tokens": 400, "output_tokens": 40,
|
|
82
|
+
"cache_read": 200, "cache_write": 200, "hit_rate": 0.5,
|
|
83
|
+
"updated_at": now}), encoding="utf-8")
|
|
84
|
+
out = cache_stats.compute(self.store, now=now)
|
|
85
|
+
self.assertEqual(out["source"], "legacy_file")
|
|
86
|
+
self.assertEqual(out["turns"], 4)
|
|
87
|
+
self.assertEqual(out["hit_rate"], 0.5)
|
|
88
|
+
|
|
89
|
+
def test_usage_event_action_appends(self):
|
|
90
|
+
out = pipeline.dispatch("usage_event", {
|
|
91
|
+
"store": str(self.store), "prompt_tokens": 30,
|
|
92
|
+
"cache_read": 15, "cache_write": 5, "now": time.time()})
|
|
93
|
+
self.assertTrue(out["ok"])
|
|
94
|
+
self.assertEqual(out["cache"]["cache_read"], 15)
|
|
95
|
+
|
|
96
|
+
def test_stats_action_includes_core_cache(self):
|
|
97
|
+
cache_stats.record_event(self.store, {"prompt_tokens": 10,
|
|
98
|
+
"cache_read": 5, "cache_write": 5}, now=time.time())
|
|
99
|
+
out = pipeline.dispatch("stats", {"store": str(self.store)})
|
|
100
|
+
self.assertIn("cache", out)
|
|
101
|
+
self.assertEqual(out["cache"]["source"], "usage_events")
|
|
102
|
+
self.assertEqual(out["cache"]["hit_rate"], 0.5)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SeriesCase(unittest.TestCase):
|
|
106
|
+
def setUp(self):
|
|
107
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
108
|
+
self.data_dir = Path(self.tmp.name) / "data"
|
|
109
|
+
self.store = self.data_dir / "memory"
|
|
110
|
+
(self.store / "sessions").mkdir(parents=True)
|
|
111
|
+
|
|
112
|
+
def tearDown(self):
|
|
113
|
+
self.tmp.cleanup()
|
|
114
|
+
|
|
115
|
+
def test_empty_series_arrays(self):
|
|
116
|
+
out = pipeline.dispatch("series", {"store": str(self.store),
|
|
117
|
+
"data_dir": str(self.data_dir)})
|
|
118
|
+
self.assertTrue(out["ok"])
|
|
119
|
+
for key in ("ts", "v", "tension", "warmth", "heaviness", "clarity"):
|
|
120
|
+
self.assertIsInstance(out["series"][key], list)
|
|
121
|
+
self.assertEqual(out["series"][key], [])
|
|
122
|
+
|
|
123
|
+
def test_series_points_from_core_state(self):
|
|
124
|
+
path = core_state.path_for(data_dir=str(self.data_dir))
|
|
125
|
+
now = time.time()
|
|
126
|
+
core_state.apply(path, absolute={"v": 0.2, "stress": 0.4}, now=now)
|
|
127
|
+
core_state.apply(path, absolute={"v": 0.3, "stress": 0.2}, now=now + 60)
|
|
128
|
+
out = pipeline.dispatch("series", {"store": str(self.store),
|
|
129
|
+
"data_dir": str(self.data_dir),
|
|
130
|
+
"limit": 24})
|
|
131
|
+
self.assertTrue(out["points"])
|
|
132
|
+
self.assertLessEqual(len(out["points"]), 24)
|
|
133
|
+
self.assertEqual(len(out["series"]["ts"]), len(out["points"]))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
unittest.main()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""task-36 + task-78: tier hysteresis, reconsolidation, minimal repair loop."""
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from biocore_memory.store import tiering
|
|
10
|
+
from biocore_runtime import lifecycle, pipeline
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def write_json(path, data):
|
|
14
|
+
path = Path(path)
|
|
15
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def entry(i, text="记得这件事", ts=None, hits=1, importance=0.5):
|
|
20
|
+
return {"id": i, "text": text, "role": "user",
|
|
21
|
+
"ts": ts or (time.time() - 3600), "importance": importance,
|
|
22
|
+
"hits": hits}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Case(unittest.TestCase):
|
|
26
|
+
def setUp(self):
|
|
27
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
28
|
+
self.data_dir = Path(self.tmp.name) / "data"
|
|
29
|
+
self.store = self.data_dir / "memory"
|
|
30
|
+
(self.store / "sessions" / "s1").mkdir(parents=True)
|
|
31
|
+
self.persona = Path(self.tmp.name) / "missing.json"
|
|
32
|
+
|
|
33
|
+
def tearDown(self):
|
|
34
|
+
self.tmp.cleanup()
|
|
35
|
+
|
|
36
|
+
def write_entries(self, entries):
|
|
37
|
+
write_json(self.store / "sessions" / "s1" / "memory.json",
|
|
38
|
+
{"entries": entries})
|
|
39
|
+
return entries
|
|
40
|
+
|
|
41
|
+
def read_entries(self):
|
|
42
|
+
return json.loads((self.store / "sessions" / "s1" / "memory.json")
|
|
43
|
+
.read_text(encoding="utf-8"))["entries"]
|
|
44
|
+
|
|
45
|
+
def payload(self, **extra):
|
|
46
|
+
data = {"store": str(self.store), "session_id": "s1",
|
|
47
|
+
"persona_profile": str(self.persona)}
|
|
48
|
+
data.update(extra)
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TierTest(Case):
|
|
53
|
+
def test_enter_hot_at_five_hits(self):
|
|
54
|
+
self.assertEqual(tiering.tier_of(entry("a", hits=5), now=time.time()),
|
|
55
|
+
"hot")
|
|
56
|
+
|
|
57
|
+
def test_stays_hot_between_three_and_five(self):
|
|
58
|
+
now = time.time()
|
|
59
|
+
self.assertEqual(tiering.tier_of(entry("a", hits=4), now=now,
|
|
60
|
+
previous="hot"), "hot")
|
|
61
|
+
|
|
62
|
+
def test_exits_hot_below_three(self):
|
|
63
|
+
now = time.time()
|
|
64
|
+
self.assertNotEqual(tiering.tier_of(entry("a", hits=2), now=now,
|
|
65
|
+
previous="hot"), "hot")
|
|
66
|
+
|
|
67
|
+
def test_tick_counts_entered_exited(self):
|
|
68
|
+
now = time.time()
|
|
69
|
+
old_ts = time.time() - 3 * 86400
|
|
70
|
+
rows = [entry("a", hits=5), entry("b", hits=2, ts=old_ts)]
|
|
71
|
+
first = lifecycle.tier_tick(self.store, rows, now=now)
|
|
72
|
+
self.assertEqual(first["counts"]["hot"], 1)
|
|
73
|
+
rows[0]["hits"] = 1
|
|
74
|
+
second = lifecycle.tier_tick(self.store, rows, now=now + 1)
|
|
75
|
+
self.assertGreaterEqual(second["exited"], 1)
|
|
76
|
+
summary = lifecycle.tier_summary(self.store)
|
|
77
|
+
self.assertIn("hot", summary)
|
|
78
|
+
self.assertIn("exited", summary)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ReconsolidationTest(Case):
|
|
82
|
+
def test_updates_recalled_entry(self):
|
|
83
|
+
now = time.time()
|
|
84
|
+
self.write_entries([entry("a", "苹果很甜", hits=1)])
|
|
85
|
+
lifecycle.record_recall(self.store, "s1", ["a"], now=now)
|
|
86
|
+
result = lifecycle.reconsolidate(self.store, now=now + 60)
|
|
87
|
+
self.assertEqual(result["updated"], 1)
|
|
88
|
+
row = self.read_entries()[0]
|
|
89
|
+
self.assertGreater(row.get("regenerated_at", 0), 0)
|
|
90
|
+
self.assertEqual(row.get("reconsolidation_count"), 1)
|
|
91
|
+
self.assertGreater(row.get("importance", 0), 0.5)
|
|
92
|
+
self.assertEqual(len(self.read_entries()), 1)
|
|
93
|
+
|
|
94
|
+
def test_idempotent_second_call(self):
|
|
95
|
+
now = time.time()
|
|
96
|
+
self.write_entries([entry("a", hits=1)])
|
|
97
|
+
lifecycle.record_recall(self.store, "s1", ["a"], now=now)
|
|
98
|
+
lifecycle.reconsolidate(self.store, now=now + 60)
|
|
99
|
+
again = lifecycle.reconsolidate(self.store, now=now + 61)
|
|
100
|
+
self.assertEqual(again["updated"], 0)
|
|
101
|
+
self.assertGreaterEqual(again["skipped"], 1)
|
|
102
|
+
|
|
103
|
+
def test_window_expires(self):
|
|
104
|
+
now = time.time()
|
|
105
|
+
self.write_entries([entry("a", hits=1)])
|
|
106
|
+
lifecycle.record_recall(self.store, "s1", ["a"], now=now - 7 * 3600)
|
|
107
|
+
result = lifecycle.reconsolidate(self.store, now=now)
|
|
108
|
+
self.assertEqual(result["updated"], 0)
|
|
109
|
+
self.assertEqual(result["reason"], "window")
|
|
110
|
+
|
|
111
|
+
def test_no_recall_ids(self):
|
|
112
|
+
self.write_entries([entry("a")])
|
|
113
|
+
result = lifecycle.reconsolidate(self.store, now=time.time())
|
|
114
|
+
self.assertEqual(result["updated"], 0)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RepairTest(Case):
|
|
118
|
+
def test_arm_and_consume_once(self):
|
|
119
|
+
now = time.time()
|
|
120
|
+
self.assertTrue(lifecycle.arm_repair(self.store, "boundary", now=now))
|
|
121
|
+
lines = lifecycle.repair_lines(self.store, now=now + 1)
|
|
122
|
+
self.assertTrue(lines)
|
|
123
|
+
self.assertTrue(any("修复" in line for line in lines))
|
|
124
|
+
self.assertEqual(lifecycle.repair_lines(self.store, now=now + 2), [])
|
|
125
|
+
|
|
126
|
+
def test_cooldown_blocks_rearm(self):
|
|
127
|
+
now = time.time()
|
|
128
|
+
lifecycle.arm_repair(self.store, "boundary", now=now)
|
|
129
|
+
lifecycle.repair_lines(self.store, now=now + 1)
|
|
130
|
+
self.assertFalse(lifecycle.arm_repair(self.store, "boundary",
|
|
131
|
+
now=now + 2))
|
|
132
|
+
|
|
133
|
+
def test_expired_repair_returns_empty(self):
|
|
134
|
+
now = time.time()
|
|
135
|
+
lifecycle.arm_repair(self.store, "boundary", now=now)
|
|
136
|
+
self.assertEqual(lifecycle.repair_lines(self.store,
|
|
137
|
+
now=now + 31 * 60), [])
|
|
138
|
+
|
|
139
|
+
def test_state_exposes_lifecycle(self):
|
|
140
|
+
now = time.time()
|
|
141
|
+
self.write_entries([entry("a", hits=5)])
|
|
142
|
+
lifecycle.record_recall(self.store, "s1", ["a"], now=now)
|
|
143
|
+
lifecycle.tier_tick(self.store, [entry("a", hits=5)], now=now)
|
|
144
|
+
pipeline.dispatch("turn_end", self.payload(
|
|
145
|
+
user_text="好", assistant_text="嗯。", capture=False))
|
|
146
|
+
body = pipeline.dispatch("state", self.payload())["state"]
|
|
147
|
+
self.assertIn("tiers", body)
|
|
148
|
+
self.assertIn("reconsolidation", body)
|
|
149
|
+
self.assertIn("repair", body)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
unittest.main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""task-62: normalized channels, chunks, shards, predictive exploration."""
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from biocore_runtime import pipeline, retrieval_plus as rp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def entry(i, text, ts=None):
|
|
13
|
+
return {"id": i, "text": text, "role": "user",
|
|
14
|
+
"ts": ts if ts is not None else time.time()}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Case(unittest.TestCase):
|
|
18
|
+
def setUp(self):
|
|
19
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
20
|
+
self.data_dir = Path(self.tmp.name) / "data"
|
|
21
|
+
self.store = self.data_dir / "memory"
|
|
22
|
+
(self.store / "sessions" / "s1").mkdir(parents=True)
|
|
23
|
+
self.now = time.time()
|
|
24
|
+
|
|
25
|
+
def tearDown(self):
|
|
26
|
+
self.tmp.cleanup()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NormalizeTest(Case):
|
|
30
|
+
def test_min_max(self):
|
|
31
|
+
self.assertEqual(rp.normalize([0.0, 5.0, 10.0]), [0.0, 0.5, 1.0])
|
|
32
|
+
|
|
33
|
+
def test_flat_positive_is_one(self):
|
|
34
|
+
self.assertEqual(rp.normalize([2.0, 2.0]), [1.0, 1.0])
|
|
35
|
+
|
|
36
|
+
def test_flat_zero_is_zero(self):
|
|
37
|
+
self.assertEqual(rp.normalize([0.0, 0.0]), [0.0, 0.0])
|
|
38
|
+
|
|
39
|
+
def test_channels_are_independent(self):
|
|
40
|
+
rows = [entry("a", "苹果 苹果 苹果"), entry("b", "香蕉")]
|
|
41
|
+
ranked = rp.rank(self.store, "s1", "苹果", rows, now=self.now)
|
|
42
|
+
by_id = {item["entry"]["id"]: item for item in ranked}
|
|
43
|
+
self.assertEqual(by_id["a"]["channels"]["bm25"], 1.0)
|
|
44
|
+
self.assertEqual(by_id["b"]["channels"]["bm25"], 0.0)
|
|
45
|
+
self.assertIn("vector", by_id["a"]["channels"])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ChunkTest(Case):
|
|
49
|
+
def test_sentences_split(self):
|
|
50
|
+
chunks = rp.split_chunks("第一句。第二句!第三句?")
|
|
51
|
+
self.assertEqual(len(chunks), 3)
|
|
52
|
+
|
|
53
|
+
def test_index_persists(self):
|
|
54
|
+
rp.build_chunks(self.store, [entry("a", "第一句。第二句。")])
|
|
55
|
+
doc = json.loads((self.data_dir / "retrieval" / "chunks.json")
|
|
56
|
+
.read_text(encoding="utf-8"))
|
|
57
|
+
self.assertIn("a", doc["entries"])
|
|
58
|
+
self.assertEqual(len(doc["entries"]["a"]["chunks"]), 2)
|
|
59
|
+
|
|
60
|
+
def test_best_chunk_snippet(self):
|
|
61
|
+
rows = [entry("a", "今天很累。苹果很甜,想再买一次。")]
|
|
62
|
+
ranked = rp.rank(self.store, "s1", "苹果", rows, now=self.now)
|
|
63
|
+
self.assertIn("苹果", ranked[0]["chunk"])
|
|
64
|
+
self.assertGreaterEqual(ranked[0]["chunk_hits"], 1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ShardTest(Case):
|
|
68
|
+
def test_session_shard(self):
|
|
69
|
+
shards = rp.assign_shards(self.store, [entry("a", "x")], session_id="s1")
|
|
70
|
+
self.assertEqual(shards["a"], "s-s1")
|
|
71
|
+
self.assertTrue((self.data_dir / "retrieval" / "shards"
|
|
72
|
+
/ "s-s1.json").exists())
|
|
73
|
+
|
|
74
|
+
def test_month_shard_without_session(self):
|
|
75
|
+
row = entry("a", "x", ts=time.mktime((2024, 3, 15, 12, 0, 0, 0, 0, -1)))
|
|
76
|
+
shards = rp.assign_shards(self.store, [row])
|
|
77
|
+
self.assertEqual(shards["a"], "2024-03")
|
|
78
|
+
|
|
79
|
+
def test_rollover_over_cap(self):
|
|
80
|
+
rows = [entry(str(index), "x") for index in range(5)]
|
|
81
|
+
shards = rp.assign_shards(self.store, rows, session_id="s1", limit=2)
|
|
82
|
+
names = list(shards.values())
|
|
83
|
+
self.assertEqual(names.count("s-s1"), 2)
|
|
84
|
+
self.assertIn("s-s1-r1", names)
|
|
85
|
+
self.assertIn("s-s1-r2", names)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PredictionTest(Case):
|
|
89
|
+
def test_expectation_weights(self):
|
|
90
|
+
rp.record_query(self.store, "苹果 咖啡")
|
|
91
|
+
rp.record_query(self.store, "苹果 咖啡 甜")
|
|
92
|
+
rows = [entry("expected", "苹果 咖啡"), entry("novel", "维修 螺丝")]
|
|
93
|
+
ranked = rp.rank(self.store, "s1", "苹果", rows, now=self.now)
|
|
94
|
+
by_id = {item["entry"]["id"]: item for item in ranked}
|
|
95
|
+
self.assertGreater(by_id["novel"]["expectation_weight"],
|
|
96
|
+
by_id["expected"]["expectation_weight"])
|
|
97
|
+
|
|
98
|
+
def test_topic_history_bounded(self):
|
|
99
|
+
for index in range(25):
|
|
100
|
+
rp.record_query(self.store, "话题%d" % index)
|
|
101
|
+
doc = json.loads((self.data_dir / "topic_history.json")
|
|
102
|
+
.read_text(encoding="utf-8"))
|
|
103
|
+
self.assertEqual(len(doc["turns"]), 20)
|
|
104
|
+
|
|
105
|
+
def test_off_fallback(self):
|
|
106
|
+
rows = [entry("a", "苹果"), entry("b", "香蕉")]
|
|
107
|
+
ranked = rp.rank(self.store, "s1", "苹果", rows, now=self.now,
|
|
108
|
+
enabled=False)
|
|
109
|
+
self.assertEqual([item["entry"]["id"] for item in ranked], ["a", "b"])
|
|
110
|
+
self.assertEqual(ranked[0]["reason"], "fallback")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class DebugTest(Case):
|
|
114
|
+
def test_debug_rows_and_state(self):
|
|
115
|
+
rows = [entry("a", "苹果很甜"), entry("b", "香蕉")]
|
|
116
|
+
ranked = rp.rank(self.store, "s1", "苹果", rows, now=self.now)
|
|
117
|
+
rp.record_debug(self.store, "苹果", ranked, now=self.now)
|
|
118
|
+
summary = rp.summary(self.store)
|
|
119
|
+
self.assertEqual(summary["query"], "苹果")
|
|
120
|
+
for key in ("channels", "reason", "chunk", "chunk_hits", "shard"):
|
|
121
|
+
self.assertIn(key, summary["rows"][0])
|
|
122
|
+
body = pipeline.dispatch("state", {
|
|
123
|
+
"store": str(self.store), "session_id": "s1",
|
|
124
|
+
"persona_profile": str(Path(self.tmp.name) / "missing.json")})["state"]
|
|
125
|
+
self.assertIn("recall_debug", body)
|
|
126
|
+
|
|
127
|
+
def test_switch_off_keeps_engine_order(self):
|
|
128
|
+
rows = [entry("old", "苹果 很甜", ts=self.now - 100),
|
|
129
|
+
entry("new", "苹果 今天", ts=self.now)]
|
|
130
|
+
(self.store / "sessions" / "s1" / "memory.json").write_text(
|
|
131
|
+
json.dumps({"entries": rows}), encoding="utf-8")
|
|
132
|
+
on = pipeline.inject_block("s1", str(self.store), query="苹果",
|
|
133
|
+
mode="query", top_k=2)
|
|
134
|
+
path = self.data_dir / "core_config.json"
|
|
135
|
+
path.write_text(json.dumps({"switches": {"retrieval_plus": False}}),
|
|
136
|
+
encoding="utf-8")
|
|
137
|
+
off = pipeline.inject_block("s1", str(self.store), query="苹果",
|
|
138
|
+
mode="query", top_k=2)
|
|
139
|
+
self.assertEqual(len(on["ids"]), len(off["ids"]))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
unittest.main()
|