switchroom 0.19.2 → 0.19.4

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 (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +109 -7
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. 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()