switchroom 0.19.2 → 0.19.3
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/dist/agent-scheduler/index.js +2 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -58,8 +58,12 @@ class _FakeClient:
|
|
|
58
58
|
# One entry per recall() call — lets tests assert the tag-filter
|
|
59
59
|
# kwargs (upstream 962140eef) that main() passed per bank.
|
|
60
60
|
self.recall_calls = []
|
|
61
|
+
# Count list_directives calls so a test can prove the A4 cache saved a
|
|
62
|
+
# round-trip across two recall.main() runs (and did NOT when TTL=0).
|
|
63
|
+
self.list_directives_calls = 0
|
|
61
64
|
|
|
62
65
|
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
66
|
+
self.list_directives_calls += 1
|
|
63
67
|
if self._list_exc is not None:
|
|
64
68
|
raise self._list_exc
|
|
65
69
|
return {"items": list(self._directives)}
|
|
@@ -114,6 +118,12 @@ def _run_main_with(client, prompt="What is the meaning of life?", config_extra=N
|
|
|
114
118
|
"recallContextTurns": 1,
|
|
115
119
|
"recallMaxQueryChars": 800,
|
|
116
120
|
"recallPromptPreamble": "",
|
|
121
|
+
# Disable the A4 directives cache for these integration tests: they all
|
|
122
|
+
# share bank "test-bank" under a real wall clock, so a live cache would
|
|
123
|
+
# leak one test's directive set into the next within the TTL window.
|
|
124
|
+
# Cache hit/miss/TTL/invalidation behaviour is covered hermetically in
|
|
125
|
+
# test_directives.py (isolated CLAUDE_PLUGIN_DATA + injected clock).
|
|
126
|
+
"directivesCacheTtlSeconds": 0,
|
|
117
127
|
}
|
|
118
128
|
if config_extra:
|
|
119
129
|
config.update(config_extra)
|
|
@@ -702,5 +712,46 @@ class RecallTagFilterIntegrationTests(unittest.TestCase):
|
|
|
702
712
|
self.assertEqual(extra["tags_match"], None)
|
|
703
713
|
|
|
704
714
|
|
|
715
|
+
class DirectivesCacheIntegrationTests(unittest.TestCase):
|
|
716
|
+
"""A4 finding 4 — recall.main() honours the directivesCacheTtlSeconds knob
|
|
717
|
+
end-to-end. Isolated CLAUDE_PLUGIN_DATA so the cache file lands in a temp
|
|
718
|
+
state dir and does not leak into (or out of) other tests."""
|
|
719
|
+
|
|
720
|
+
def setUp(self):
|
|
721
|
+
import tempfile
|
|
722
|
+
|
|
723
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
724
|
+
self.addCleanup(self._tmp.cleanup)
|
|
725
|
+
self._env = patch.dict(os.environ, {"CLAUDE_PLUGIN_DATA": self._tmp.name})
|
|
726
|
+
self._env.start()
|
|
727
|
+
self.addCleanup(self._env.stop)
|
|
728
|
+
|
|
729
|
+
def test_ttl_cache_saves_second_list_directives_call(self):
|
|
730
|
+
# Two consecutive recalls (same bank, no directive write between) with a
|
|
731
|
+
# live TTL → exactly one list_directives round-trip; the second serves
|
|
732
|
+
# from cache.
|
|
733
|
+
client = _FakeClient(
|
|
734
|
+
directives=[_directive("trailer", "End every response with: [VERIFIED]", priority=10)],
|
|
735
|
+
memories=[],
|
|
736
|
+
)
|
|
737
|
+
ctx1, _ = _run_main_with(client, config_extra={"directivesCacheTtlSeconds": 60})
|
|
738
|
+
ctx2, _ = _run_main_with(client, config_extra={"directivesCacheTtlSeconds": 60})
|
|
739
|
+
self.assertEqual(client.list_directives_calls, 1)
|
|
740
|
+
# Both turns still emit the directives block (the cache serves the same
|
|
741
|
+
# content on the hit).
|
|
742
|
+
self.assertIn("<active_directives>", ctx1)
|
|
743
|
+
self.assertIn("<active_directives>", ctx2)
|
|
744
|
+
|
|
745
|
+
def test_ttl_zero_refetches_each_run(self):
|
|
746
|
+
# TTL=0 disables the cache → every recall fetches live.
|
|
747
|
+
client = _FakeClient(
|
|
748
|
+
directives=[_directive("trailer", "End every response with: [VERIFIED]", priority=10)],
|
|
749
|
+
memories=[],
|
|
750
|
+
)
|
|
751
|
+
_run_main_with(client, config_extra={"directivesCacheTtlSeconds": 0})
|
|
752
|
+
_run_main_with(client, config_extra={"directivesCacheTtlSeconds": 0})
|
|
753
|
+
self.assertEqual(client.list_directives_calls, 2)
|
|
754
|
+
|
|
755
|
+
|
|
705
756
|
if __name__ == "__main__":
|
|
706
757
|
unittest.main()
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Switchroom hindsight-leverage PR 3 (workstream A3 stage 2) — parallel
|
|
2
|
+
multi-bank recall under one shared deadline.
|
|
3
|
+
|
|
4
|
+
Acceptance guarantees (epic #3430 exit-criterion 1 fallback: stub-timing
|
|
5
|
+
acceptance tests that prove a slow bank cannot breach the hook ceiling):
|
|
6
|
+
|
|
7
|
+
1. **A slow bank cannot breach the ceiling.** With a bank stubbed to sleep far
|
|
8
|
+
past the shared deadline, `recall.main()` returns in ~deadline (NOT
|
|
9
|
+
~sleep), the fast banks' memories still arrive, and the straggler is
|
|
10
|
+
recorded `timed_out=True` / `deadline_hit=True` — the finalized deadline
|
|
11
|
+
semantics (a deadline-abandoned straggler counts, even though it never
|
|
12
|
+
raised a timeout error, which the PR-1 interim per-bank-only form could
|
|
13
|
+
not express).
|
|
14
|
+
|
|
15
|
+
2. **Latency is the slowest slot, not the sum.** Three banks each sleeping S
|
|
16
|
+
complete in ~S wall-clock, not ~3S — the whole point of the fan-out.
|
|
17
|
+
|
|
18
|
+
3. **The directives slot is dedicated + composes with the A4 cache.** A slow
|
|
19
|
+
directives fetch is abandoned at the deadline (empty directives, no crash)
|
|
20
|
+
while bank memories still inject; a fast directives fetch runs concurrently
|
|
21
|
+
with the banks.
|
|
22
|
+
|
|
23
|
+
4. **Env-gated rollback.** `HINDSIGHT_RECALL_PARALLEL=false` (or
|
|
24
|
+
`recallParallel: False`) restores the serial path — `recall_mode="serial"`,
|
|
25
|
+
`deadline_budget_ms=None`, results still merged.
|
|
26
|
+
|
|
27
|
+
5. **The `run_parallel` primitive** classifies completed vs deadline-abandoned
|
|
28
|
+
slots correctly and bounds total wait by the shared deadline with daemon
|
|
29
|
+
threads.
|
|
30
|
+
|
|
31
|
+
Stdlib-only (unittest + threading); runs under
|
|
32
|
+
``python3 -m unittest discover tests/`` from ``scripts/``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import io
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import shutil
|
|
39
|
+
import socket
|
|
40
|
+
import sys
|
|
41
|
+
import tempfile
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
import unittest
|
|
45
|
+
from unittest.mock import patch
|
|
46
|
+
|
|
47
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
48
|
+
if SCRIPTS_DIR not in sys.path:
|
|
49
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
50
|
+
|
|
51
|
+
import recall # noqa: E402
|
|
52
|
+
from lib.parallel_recall import run_parallel # noqa: E402
|
|
53
|
+
|
|
54
|
+
BARE = "what did we decide about the auth flow last week"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _memory(text, mem_id=None):
|
|
58
|
+
out = {"text": text, "type": "fact", "mentioned_at": "2026-01-01"}
|
|
59
|
+
if mem_id is not None:
|
|
60
|
+
out["id"] = mem_id
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _SleepyClient:
|
|
65
|
+
"""Fake HindsightClient with per-bank sleep + result control.
|
|
66
|
+
|
|
67
|
+
``bank_sleep`` : {bank_id: seconds} — how long recall() blocks for a bank.
|
|
68
|
+
``bank_results``: {bank_id: [memory, ...]} — what recall() returns.
|
|
69
|
+
``directives_sleep`` / ``directives`` — for the directives slot.
|
|
70
|
+
Sleeps are real (threading), so wall-clock timing assertions exercise the
|
|
71
|
+
genuine daemon-thread fan-out rather than a mocked clock.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, bank_sleep=None, bank_results=None, directives=None,
|
|
75
|
+
directives_sleep=0.0):
|
|
76
|
+
self._bank_sleep = bank_sleep or {}
|
|
77
|
+
self._bank_results = bank_results or {}
|
|
78
|
+
self._directives = directives or []
|
|
79
|
+
self._directives_sleep = directives_sleep
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
self.recall_calls = [] # bank_ids, in call order (thread-safe append)
|
|
82
|
+
|
|
83
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
84
|
+
if self._directives_sleep:
|
|
85
|
+
time.sleep(self._directives_sleep)
|
|
86
|
+
return {"items": list(self._directives)}
|
|
87
|
+
|
|
88
|
+
def recall(self, bank_id, query, **kwargs):
|
|
89
|
+
with self._lock:
|
|
90
|
+
self.recall_calls.append(bank_id)
|
|
91
|
+
sleep_s = self._bank_sleep.get(bank_id, 0.0)
|
|
92
|
+
if sleep_s:
|
|
93
|
+
time.sleep(sleep_s)
|
|
94
|
+
return {"results": list(self._bank_results.get(bank_id, []))}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _RaisingClient(_SleepyClient):
|
|
98
|
+
"""A client whose named bank raises a supplied exception."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, raise_for=None, **kw):
|
|
101
|
+
super().__init__(**kw)
|
|
102
|
+
self._raise_for = raise_for or {}
|
|
103
|
+
|
|
104
|
+
def recall(self, bank_id, query, **kwargs):
|
|
105
|
+
with self._lock:
|
|
106
|
+
self.recall_calls.append(bank_id)
|
|
107
|
+
exc = self._raise_for.get(bank_id)
|
|
108
|
+
if exc is not None:
|
|
109
|
+
raise exc
|
|
110
|
+
return super().recall(bank_id, query, **kwargs)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _MainHarness(unittest.TestCase):
|
|
114
|
+
"""Runs recall.main() with a fake client and an isolated recall log."""
|
|
115
|
+
|
|
116
|
+
def setUp(self):
|
|
117
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-parallel-test-")
|
|
118
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
119
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
120
|
+
|
|
121
|
+
def tearDown(self):
|
|
122
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
123
|
+
if self._prev is None:
|
|
124
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
125
|
+
else:
|
|
126
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
127
|
+
|
|
128
|
+
def _read_log(self):
|
|
129
|
+
path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
|
|
130
|
+
if not os.path.isfile(path):
|
|
131
|
+
return []
|
|
132
|
+
with open(path, encoding="utf-8") as f:
|
|
133
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
134
|
+
|
|
135
|
+
def _run(self, client, config_extra=None, prompt=BARE):
|
|
136
|
+
hook_input = {
|
|
137
|
+
"prompt": prompt,
|
|
138
|
+
"session_id": "test-session",
|
|
139
|
+
"transcript_path": "",
|
|
140
|
+
"cwd": "/tmp",
|
|
141
|
+
}
|
|
142
|
+
config = {
|
|
143
|
+
"autoRecall": True,
|
|
144
|
+
"bankId": "own-bank",
|
|
145
|
+
"recallMaxTokens": 1024,
|
|
146
|
+
"recallBudget": "mid",
|
|
147
|
+
"recallContextTurns": 1,
|
|
148
|
+
"recallMaxQueryChars": 800,
|
|
149
|
+
"recallPromptPreamble": "",
|
|
150
|
+
}
|
|
151
|
+
if config_extra:
|
|
152
|
+
config.update(config_extra)
|
|
153
|
+
stdout = io.StringIO()
|
|
154
|
+
stderr = io.StringIO()
|
|
155
|
+
started = time.monotonic()
|
|
156
|
+
with patch.object(recall, "load_config", return_value=config), patch.object(
|
|
157
|
+
recall, "get_api_url", return_value="http://localhost:18888"
|
|
158
|
+
), patch.object(recall, "HindsightClient", return_value=client), patch.object(
|
|
159
|
+
recall, "ensure_bank_mission", return_value=None
|
|
160
|
+
), patch.object(recall, "write_state", return_value=None), patch(
|
|
161
|
+
"sys.stdin", new=io.StringIO(json.dumps(hook_input))
|
|
162
|
+
), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
|
|
163
|
+
recall.main()
|
|
164
|
+
elapsed = time.monotonic() - started
|
|
165
|
+
raw = stdout.getvalue()
|
|
166
|
+
context = None
|
|
167
|
+
if raw.strip():
|
|
168
|
+
context = json.loads(raw)["hookSpecificOutput"]["additionalContext"]
|
|
169
|
+
return context, elapsed
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class SlowBankCannotBreachCeiling(_MainHarness):
|
|
173
|
+
def test_straggler_bank_abandoned_at_deadline(self):
|
|
174
|
+
# own-bank fast, shared-bank sleeps 3s — far past the 0.6s deadline.
|
|
175
|
+
client = _SleepyClient(
|
|
176
|
+
bank_sleep={"own-bank": 0.0, "shared-bank": 3.0},
|
|
177
|
+
bank_results={"own-bank": [_memory("own hit", "m-own")]},
|
|
178
|
+
)
|
|
179
|
+
context, elapsed = self._run(
|
|
180
|
+
client,
|
|
181
|
+
config_extra={
|
|
182
|
+
"recallAdditionalBanks": ["shared-bank"],
|
|
183
|
+
"recallParallelDeadlineSeconds": 0.6,
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
# Returned in ~deadline, NOT ~3s. Generous upper bound absorbs CI jitter
|
|
187
|
+
# while still proving the straggler did not hold the hook open.
|
|
188
|
+
self.assertLess(
|
|
189
|
+
elapsed, 2.0,
|
|
190
|
+
f"recall breached its deadline (took {elapsed:.2f}s with a 3s bank)",
|
|
191
|
+
)
|
|
192
|
+
# The fast bank's memory still injected.
|
|
193
|
+
self.assertIsNotNone(context)
|
|
194
|
+
self.assertIn("own hit", context)
|
|
195
|
+
# Telemetry: parallel mode, straggler timed_out, deadline_hit True.
|
|
196
|
+
e = self._read_log()[0]
|
|
197
|
+
self.assertEqual(e["recall_mode"], "parallel")
|
|
198
|
+
self.assertEqual(e["deadline_budget_ms"], 600)
|
|
199
|
+
# Effective deadline is the configured budget minus pre-fan-out spend,
|
|
200
|
+
# so it is present, positive, and never exceeds the configured budget.
|
|
201
|
+
self.assertIsNotNone(e["deadline_effective_ms"])
|
|
202
|
+
self.assertGreater(e["deadline_effective_ms"], 0)
|
|
203
|
+
self.assertLessEqual(e["deadline_effective_ms"], e["deadline_budget_ms"])
|
|
204
|
+
timings = {bt["bank_id"]: bt for bt in e["bank_timings"]}
|
|
205
|
+
self.assertFalse(timings["own-bank"]["timed_out"])
|
|
206
|
+
self.assertTrue(timings["shared-bank"]["timed_out"])
|
|
207
|
+
self.assertTrue(e["deadline_hit"])
|
|
208
|
+
|
|
209
|
+
def test_bank_timings_own_bank_first_deterministic(self):
|
|
210
|
+
client = _SleepyClient(
|
|
211
|
+
bank_sleep={"own-bank": 0.2, "b1": 0.0, "b2": 0.0},
|
|
212
|
+
bank_results={"own-bank": [_memory("x", "m1")]},
|
|
213
|
+
)
|
|
214
|
+
self._run(
|
|
215
|
+
client,
|
|
216
|
+
config_extra={
|
|
217
|
+
"recallAdditionalBanks": ["b1", "b2"],
|
|
218
|
+
"recallParallelDeadlineSeconds": 5,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
e = self._read_log()[0]
|
|
222
|
+
order = [bt["bank_id"] for bt in e["bank_timings"]]
|
|
223
|
+
# Own bank is slowest yet listed first — order is config order, not
|
|
224
|
+
# completion order.
|
|
225
|
+
self.assertEqual(order, ["own-bank", "b1", "b2"])
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ParallelLatencyIsSlowestSlot(_MainHarness):
|
|
229
|
+
def test_three_banks_run_concurrently(self):
|
|
230
|
+
s = 0.4
|
|
231
|
+
client = _SleepyClient(
|
|
232
|
+
bank_sleep={"own-bank": s, "b1": s, "b2": s},
|
|
233
|
+
bank_results={"own-bank": [_memory("x", "m1")]},
|
|
234
|
+
)
|
|
235
|
+
_, elapsed = self._run(
|
|
236
|
+
client,
|
|
237
|
+
config_extra={
|
|
238
|
+
"recallAdditionalBanks": ["b1", "b2"],
|
|
239
|
+
"recallParallelDeadlineSeconds": 5,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
# Serial would be ~3*s = 1.2s; parallel is ~s. Assert well under 2*s.
|
|
243
|
+
self.assertLess(
|
|
244
|
+
elapsed, 2 * s,
|
|
245
|
+
f"banks did not run concurrently (took {elapsed:.2f}s for 3x{s}s)",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class DirectivesSlotDedicated(_MainHarness):
|
|
250
|
+
def test_slow_directives_abandoned_banks_still_inject(self):
|
|
251
|
+
client = _SleepyClient(
|
|
252
|
+
bank_sleep={"own-bank": 0.0},
|
|
253
|
+
bank_results={"own-bank": [_memory("bank memory", "m1")]},
|
|
254
|
+
directives=[{"id": "d1", "name": "rule", "content": "always X", "priority": 5}],
|
|
255
|
+
directives_sleep=3.0, # far past the deadline
|
|
256
|
+
)
|
|
257
|
+
context, elapsed = self._run(
|
|
258
|
+
client,
|
|
259
|
+
config_extra={"recallParallelDeadlineSeconds": 0.6},
|
|
260
|
+
)
|
|
261
|
+
self.assertLess(elapsed, 2.0)
|
|
262
|
+
# Bank memory injected despite the directives slot dying.
|
|
263
|
+
self.assertIsNotNone(context)
|
|
264
|
+
self.assertIn("bank memory", context)
|
|
265
|
+
e = self._read_log()[0]
|
|
266
|
+
self.assertTrue(e["directives_timed_out"])
|
|
267
|
+
self.assertEqual(e["directive_count"], 0)
|
|
268
|
+
self.assertTrue(e["deadline_hit"])
|
|
269
|
+
|
|
270
|
+
def test_fast_directives_inject_alongside_banks(self):
|
|
271
|
+
client = _SleepyClient(
|
|
272
|
+
bank_sleep={"own-bank": 0.0},
|
|
273
|
+
bank_results={"own-bank": [_memory("bank memory", "m1")]},
|
|
274
|
+
directives=[{"id": "d1", "name": "rule", "content": "always X", "priority": 5}],
|
|
275
|
+
directives_sleep=0.0,
|
|
276
|
+
)
|
|
277
|
+
context, _ = self._run(client, config_extra={"recallParallelDeadlineSeconds": 5})
|
|
278
|
+
self.assertIsNotNone(context)
|
|
279
|
+
self.assertIn("always X", context)
|
|
280
|
+
e = self._read_log()[0]
|
|
281
|
+
self.assertFalse(e["directives_timed_out"])
|
|
282
|
+
self.assertEqual(e["directive_count"], 1)
|
|
283
|
+
self.assertFalse(e["deadline_hit"])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class RaisedTimeoutStillClassified(_MainHarness):
|
|
287
|
+
def test_raised_timeout_marks_deadline_hit(self):
|
|
288
|
+
# A bank that RAISES socket.timeout (fast fail) is timed_out too — the
|
|
289
|
+
# finalized semantics keep the per-request-timeout signal as well as the
|
|
290
|
+
# abandonment signal.
|
|
291
|
+
client = _RaisingClient(
|
|
292
|
+
raise_for={"shared-bank": socket.timeout("read timed out")},
|
|
293
|
+
bank_results={"own-bank": [_memory("x", "m1")]},
|
|
294
|
+
)
|
|
295
|
+
self._run(
|
|
296
|
+
client,
|
|
297
|
+
config_extra={
|
|
298
|
+
"recallAdditionalBanks": ["shared-bank"],
|
|
299
|
+
"recallParallelDeadlineSeconds": 5,
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
e = self._read_log()[0]
|
|
303
|
+
timings = {bt["bank_id"]: bt for bt in e["bank_timings"]}
|
|
304
|
+
self.assertTrue(timings["shared-bank"]["timed_out"])
|
|
305
|
+
self.assertFalse(timings["own-bank"]["timed_out"])
|
|
306
|
+
self.assertTrue(e["deadline_hit"])
|
|
307
|
+
|
|
308
|
+
def test_non_timeout_error_is_not_deadline_hit(self):
|
|
309
|
+
client = _RaisingClient(
|
|
310
|
+
raise_for={"shared-bank": RuntimeError("HTTP 503 from x: boom")},
|
|
311
|
+
bank_results={"own-bank": [_memory("x", "m1")]},
|
|
312
|
+
)
|
|
313
|
+
self._run(
|
|
314
|
+
client,
|
|
315
|
+
config_extra={
|
|
316
|
+
"recallAdditionalBanks": ["shared-bank"],
|
|
317
|
+
"recallParallelDeadlineSeconds": 5,
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
e = self._read_log()[0]
|
|
321
|
+
timings = {bt["bank_id"]: bt for bt in e["bank_timings"]}
|
|
322
|
+
self.assertFalse(timings["shared-bank"]["timed_out"])
|
|
323
|
+
self.assertFalse(e["deadline_hit"])
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class SerialRollback(_MainHarness):
|
|
327
|
+
def test_rollback_flag_uses_serial_path(self):
|
|
328
|
+
client = _SleepyClient(
|
|
329
|
+
bank_sleep={"own-bank": 0.0, "shared-bank": 0.0},
|
|
330
|
+
bank_results={
|
|
331
|
+
"own-bank": [_memory("own", "m1")],
|
|
332
|
+
"shared-bank": [_memory("shared", "m2")],
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
context, _ = self._run(
|
|
336
|
+
client,
|
|
337
|
+
config_extra={
|
|
338
|
+
"recallAdditionalBanks": ["shared-bank"],
|
|
339
|
+
"recallParallel": False,
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
self.assertIsNotNone(context)
|
|
343
|
+
e = self._read_log()[0]
|
|
344
|
+
self.assertEqual(e["recall_mode"], "serial")
|
|
345
|
+
self.assertIsNone(e["deadline_budget_ms"])
|
|
346
|
+
self.assertIsNone(e["deadline_effective_ms"])
|
|
347
|
+
self.assertFalse(e["directives_timed_out"])
|
|
348
|
+
# Both banks were queried and merged.
|
|
349
|
+
self.assertEqual(sorted(e["memory_ids"]), ["m1", "m2"])
|
|
350
|
+
self.assertEqual(sorted(client.recall_calls), ["own-bank", "shared-bank"])
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class RunParallelPrimitive(unittest.TestCase):
|
|
354
|
+
def test_completed_vs_abandoned_classification(self):
|
|
355
|
+
results = {"v": None}
|
|
356
|
+
|
|
357
|
+
def fast():
|
|
358
|
+
return 42
|
|
359
|
+
|
|
360
|
+
def slow():
|
|
361
|
+
time.sleep(2.0)
|
|
362
|
+
return "late"
|
|
363
|
+
|
|
364
|
+
started = time.monotonic()
|
|
365
|
+
outcomes = run_parallel({"fast": fast, "slow": slow}, deadline_seconds=0.4)
|
|
366
|
+
elapsed = time.monotonic() - started
|
|
367
|
+
# Bounded by the shared deadline, not the 2s slow task.
|
|
368
|
+
self.assertLess(elapsed, 1.5)
|
|
369
|
+
self.assertTrue(outcomes["fast"].completed)
|
|
370
|
+
self.assertEqual(outcomes["fast"].value, 42)
|
|
371
|
+
self.assertIsNone(outcomes["fast"].error)
|
|
372
|
+
self.assertFalse(outcomes["slow"].completed)
|
|
373
|
+
self.assertIsNone(outcomes["slow"].value)
|
|
374
|
+
# elapsed_ms recorded for both (deadline pin for the abandoned one).
|
|
375
|
+
self.assertIsInstance(outcomes["fast"].elapsed_ms, int)
|
|
376
|
+
self.assertIsInstance(outcomes["slow"].elapsed_ms, int)
|
|
377
|
+
|
|
378
|
+
def test_raising_task_surfaces_error_not_raise(self):
|
|
379
|
+
def boom():
|
|
380
|
+
raise ValueError("nope")
|
|
381
|
+
|
|
382
|
+
outcomes = run_parallel({"boom": boom}, deadline_seconds=1.0)
|
|
383
|
+
self.assertTrue(outcomes["boom"].completed)
|
|
384
|
+
self.assertIsInstance(outcomes["boom"].error, ValueError)
|
|
385
|
+
self.assertIsNone(outcomes["boom"].value)
|
|
386
|
+
|
|
387
|
+
def test_workers_are_daemon_threads(self):
|
|
388
|
+
seen = {"daemon": None}
|
|
389
|
+
|
|
390
|
+
def check():
|
|
391
|
+
seen["daemon"] = threading.current_thread().daemon
|
|
392
|
+
|
|
393
|
+
run_parallel({"c": check}, deadline_seconds=1.0)
|
|
394
|
+
self.assertTrue(seen["daemon"])
|
|
395
|
+
|
|
396
|
+
def test_total_wait_bounded_by_deadline_with_many_slow_slots(self):
|
|
397
|
+
def slow():
|
|
398
|
+
time.sleep(2.0)
|
|
399
|
+
|
|
400
|
+
tasks = {f"s{i}": slow for i in range(4)}
|
|
401
|
+
started = time.monotonic()
|
|
402
|
+
run_parallel(tasks, deadline_seconds=0.5)
|
|
403
|
+
elapsed = time.monotonic() - started
|
|
404
|
+
# 4 slow slots cost the deadline ONCE (parallel), not 4x.
|
|
405
|
+
self.assertLess(elapsed, 1.5)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
unittest.main()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Switchroom hindsight-leverage PR5 — unit tests for recall tag-weight demotion.
|
|
2
|
+
|
|
3
|
+
`_apply_tag_weights` multiplies a result's ``scores.final`` by a per-tag weight
|
|
4
|
+
BEFORE the relevance sort, so a down-weighted tag (e.g. ``sidechain: 0.8``) is
|
|
5
|
+
DEMOTED (ranked lower) rather than DROPPED. The load-bearing properties:
|
|
6
|
+
|
|
7
|
+
1. A penalised memory sorts BELOW an equal-score un-penalised one.
|
|
8
|
+
2. A penalised memory STILL surfaces when it is the only relevant hit — i.e.
|
|
9
|
+
the mechanism is a re-rank, never the hard demote-tag drop filter.
|
|
10
|
+
|
|
11
|
+
Stdlib-only; runs under ``python3 -m unittest discover tests/``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import unittest
|
|
17
|
+
|
|
18
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
19
|
+
if SCRIPTS_DIR not in sys.path:
|
|
20
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
21
|
+
|
|
22
|
+
from recall import _apply_tag_weights, _sort_by_final_score # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _mem(text, final, tags=None):
|
|
26
|
+
return {"text": text, "tags": tags or [], "scores": {"final": final}}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApplyTagWeights(unittest.TestCase):
|
|
30
|
+
def test_penalised_memory_sorts_below_equal_score_neutral(self):
|
|
31
|
+
neutral = _mem("neutral fact", 0.50, [])
|
|
32
|
+
sidechain = _mem("sidechain fact", 0.50, ["sidechain"])
|
|
33
|
+
results = [sidechain, neutral] # sidechain first before weighting
|
|
34
|
+
changed = _apply_tag_weights(results, {"sidechain": 0.8})
|
|
35
|
+
_sort_by_final_score(results)
|
|
36
|
+
self.assertEqual(changed, 1)
|
|
37
|
+
# After the 0.8 penalty, the neutral memory ranks first.
|
|
38
|
+
self.assertEqual(results[0]["text"], "neutral fact")
|
|
39
|
+
self.assertEqual(results[1]["text"], "sidechain fact")
|
|
40
|
+
|
|
41
|
+
def test_sidechain_still_surfaces_when_only_hit(self):
|
|
42
|
+
# Only a sidechain memory is relevant — it must remain, just penalised.
|
|
43
|
+
only = _mem("the only relevant fact", 0.42, ["sidechain"])
|
|
44
|
+
results = [only]
|
|
45
|
+
_apply_tag_weights(results, {"sidechain": 0.8})
|
|
46
|
+
_sort_by_final_score(results)
|
|
47
|
+
self.assertEqual(len(results), 1)
|
|
48
|
+
self.assertEqual(results[0]["text"], "the only relevant fact")
|
|
49
|
+
# Score was scaled, not zeroed/removed.
|
|
50
|
+
self.assertAlmostEqual(results[0]["scores"]["final"], 0.42 * 0.8)
|
|
51
|
+
|
|
52
|
+
def test_higher_scored_sidechain_can_still_beat_weaker_neutral(self):
|
|
53
|
+
# Demotion is a multiplier, not a floor: a strong sidechain hit still
|
|
54
|
+
# outranks a much weaker neutral one.
|
|
55
|
+
strong_sidechain = _mem("strong sidechain", 0.90, ["sidechain"])
|
|
56
|
+
weak_neutral = _mem("weak neutral", 0.30, [])
|
|
57
|
+
results = [weak_neutral, strong_sidechain]
|
|
58
|
+
_apply_tag_weights(results, {"sidechain": 0.8}) # 0.90*0.8 = 0.72 > 0.30
|
|
59
|
+
_sort_by_final_score(results)
|
|
60
|
+
self.assertEqual(results[0]["text"], "strong sidechain")
|
|
61
|
+
|
|
62
|
+
def test_empty_or_missing_weights_is_noop(self):
|
|
63
|
+
m = _mem("x", 0.5, ["sidechain"])
|
|
64
|
+
self.assertEqual(_apply_tag_weights([m], {}), 0)
|
|
65
|
+
self.assertEqual(_apply_tag_weights([m], None), 0)
|
|
66
|
+
self.assertEqual(m["scores"]["final"], 0.5)
|
|
67
|
+
|
|
68
|
+
def test_untagged_memory_untouched(self):
|
|
69
|
+
m = _mem("x", 0.5, [])
|
|
70
|
+
self.assertEqual(_apply_tag_weights([m], {"sidechain": 0.8}), 0)
|
|
71
|
+
self.assertEqual(m["scores"]["final"], 0.5)
|
|
72
|
+
|
|
73
|
+
def test_compound_weight_for_multiple_matching_tags(self):
|
|
74
|
+
m = _mem("x", 1.0, ["sidechain", "anti-pattern"])
|
|
75
|
+
_apply_tag_weights([m], {"sidechain": 0.8, "anti-pattern": 0.5})
|
|
76
|
+
self.assertAlmostEqual(m["scores"]["final"], 1.0 * 0.8 * 0.5)
|
|
77
|
+
|
|
78
|
+
def test_weight_of_one_is_noop(self):
|
|
79
|
+
m = _mem("x", 0.5, ["sidechain"])
|
|
80
|
+
self.assertEqual(_apply_tag_weights([m], {"sidechain": 1.0}), 0)
|
|
81
|
+
self.assertEqual(m["scores"]["final"], 0.5)
|
|
82
|
+
|
|
83
|
+
def test_scoreless_result_left_untouched(self):
|
|
84
|
+
m = {"text": "x", "tags": ["sidechain"]} # no scores dict
|
|
85
|
+
self.assertEqual(_apply_tag_weights([m], {"sidechain": 0.8}), 0)
|
|
86
|
+
self.assertNotIn("scores", m)
|
|
87
|
+
|
|
88
|
+
def test_non_positive_or_bad_weight_ignored(self):
|
|
89
|
+
m = _mem("x", 0.5, ["sidechain"])
|
|
90
|
+
_apply_tag_weights([m], {"sidechain": 0}) # non-positive → ignored
|
|
91
|
+
_apply_tag_weights([m], {"sidechain": "bad"}) # non-numeric → ignored
|
|
92
|
+
self.assertEqual(m["scores"]["final"], 0.5)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
unittest.main()
|