switchroom 0.19.24 → 0.19.26
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 +20 -7
- package/dist/auth-broker/index.js +93 -28
- package/dist/cli/autoaccept-poll.js +0 -1
- package/dist/cli/drive-write-pretool.mjs +5 -0
- package/dist/cli/ms-365-write-pretool.mjs +5 -0
- package/dist/cli/notion-write-pretool.mjs +20 -6
- package/dist/cli/switchroom.js +3091 -1435
- package/dist/host-control/main.js +92 -29
- package/dist/vault/approvals/kernel-server.js +92 -28
- package/dist/vault/broker/server.js +258 -71
- package/examples/switchroom.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +6 -0
- package/profiles/_base/start.sh.hbs +92 -17
- package/skills/switchroom-health/SKILL.md +19 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +9 -2
- package/telegram-plugin/dist/gateway/gateway.js +6981 -6747
- package/telegram-plugin/gateway/gateway.ts +53 -52
- package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
- package/telegram-plugin/quota-bar-format.ts +4 -1
- package/telegram-plugin/status-no-truncate.ts +49 -0
- package/telegram-plugin/status-pin-driver.ts +28 -0
- package/telegram-plugin/status-pin.ts +33 -4
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
- package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
- package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
- package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
- package/telegram-plugin/tests/status-pin.test.ts +128 -2
- package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +61 -18
- package/telegram-plugin/uat/assertions.ts +21 -2
- package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +38 -17
- package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
- package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
- package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
- package/vendor/hindsight-memory/scripts/recall.py +503 -252
- package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
- package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
- package/vendor/hindsight-memory/tests/test_content.py +218 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Switchroom #3757 — the recall hook must not put an unbounded BM25 query on
|
|
2
|
+
the wire, and the per-bank timeout must be configurable.
|
|
3
|
+
|
|
4
|
+
The bug: recall.py composed a query from the last 2 turns (up to
|
|
5
|
+
``recallMaxQueryChars`` = 800 chars, ~110 tokens) and Hindsight OR-joined every
|
|
6
|
+
token into one ``to_tsquery``. Postgres native FTS cannot top-k from the GIN
|
|
7
|
+
index, so it ranked the whole matched set before the top-60 heapsort. Measured
|
|
8
|
+
on the live ``overlord`` bank (135,565 memory_units, 3 fact-type arms):
|
|
9
|
+
|
|
10
|
+
as shipped 96 terms 119,510 rows ranked 14.0 s
|
|
11
|
+
role labels/header removed 93 terms 86,653 rows ranked 11.8 s
|
|
12
|
+
+ capped to 24 terms 24 terms 48,433 rows ranked 2.7 s
|
|
13
|
+
|
|
14
|
+
(`exec` is the best of three EXPLAIN ANALYZE runs on a live, loaded host; the
|
|
15
|
+
unshaped query ranged 14.0-94.1 s across those runs while the shaped one held
|
|
16
|
+
2.54-2.76 s, so the cap removes the variance as well as the mean.)
|
|
17
|
+
|
|
18
|
+
Two of the tokens were scaffolding the hook added itself — ``user`` matched
|
|
19
|
+
67,363 rows (50% of the bank) and ``assistant`` 29,942 (22%). The 8s hardcoded
|
|
20
|
+
client timeout then fired on 96.8% of that agent's own-bank recalls, so the
|
|
21
|
+
model got zero memories on ~3 turns in 4.
|
|
22
|
+
|
|
23
|
+
Acceptance guarantees (outcomes, not code paths):
|
|
24
|
+
|
|
25
|
+
1. **The query on the wire is term-capped.** Whatever the composed query, the
|
|
26
|
+
string passed to ``client.recall`` tokenizes to at most
|
|
27
|
+
``recallQueryMaxTokens`` distinct BM25 terms.
|
|
28
|
+
|
|
29
|
+
2. **Role labels never reach the wire.** ``user`` / ``assistant`` are absent
|
|
30
|
+
from the wire query's tokens even when the composed context contains both
|
|
31
|
+
roles.
|
|
32
|
+
|
|
33
|
+
3. **The cap is configurable, not hardcoded** — including ``0`` to disable
|
|
34
|
+
shaping entirely (the rollback lever).
|
|
35
|
+
|
|
36
|
+
4. **The per-bank timeout is configurable** and defaults to 12s, not the old
|
|
37
|
+
hardcoded 8.
|
|
38
|
+
|
|
39
|
+
5. **Shaping does not leak into the client-side lexical gate.** The
|
|
40
|
+
``recallMinOverlap`` containment gate keeps measuring against the user's
|
|
41
|
+
real words, so this change cannot silently move an operator's threshold.
|
|
42
|
+
|
|
43
|
+
Stdlib-only (unittest); runs under ``python3 -m unittest discover tests/``
|
|
44
|
+
from ``scripts/``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import io
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import shutil
|
|
51
|
+
import sys
|
|
52
|
+
import tempfile
|
|
53
|
+
import unittest
|
|
54
|
+
from unittest.mock import patch
|
|
55
|
+
|
|
56
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
57
|
+
if SCRIPTS_DIR not in sys.path:
|
|
58
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
59
|
+
|
|
60
|
+
import recall # noqa: E402
|
|
61
|
+
from lib.content import ( # noqa: E402
|
|
62
|
+
BM25_STOPWORDS,
|
|
63
|
+
_selectivity_score,
|
|
64
|
+
common_english_words,
|
|
65
|
+
tokenize_for_bm25,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# A prior turn plus a latest turn, together well past the term budget. Written
|
|
69
|
+
# as real prose because the point is a production-shaped query, not a synthetic
|
|
70
|
+
# token soup.
|
|
71
|
+
PRIOR_USER = (
|
|
72
|
+
"the v0.19.24 rollout went out this morning and the reaper never swept the "
|
|
73
|
+
"orphaned worktrees on the build host, which left eleven stale claims behind"
|
|
74
|
+
)
|
|
75
|
+
PRIOR_ASSISTANT = (
|
|
76
|
+
"I compared the published manifest digest against what the agent container "
|
|
77
|
+
"actually pulled and they diverge, so the restart raced the tag and the "
|
|
78
|
+
"container is running the previous image entirely"
|
|
79
|
+
)
|
|
80
|
+
LATEST = (
|
|
81
|
+
"why did recall for the v0.19.24 rollout return v0.18.15 instead of the "
|
|
82
|
+
"release notes from this morning"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _nested_line(role, text):
|
|
87
|
+
return json.dumps({
|
|
88
|
+
"type": role,
|
|
89
|
+
"uuid": f"u-{abs(hash((role, text))) % 10_000_000}",
|
|
90
|
+
"message": {"role": role, "content": text},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Client:
|
|
95
|
+
"""Fake HindsightClient that records exactly what went on the wire."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, results=None):
|
|
98
|
+
self._results = results or []
|
|
99
|
+
self.calls = []
|
|
100
|
+
|
|
101
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
102
|
+
return {"items": []}
|
|
103
|
+
|
|
104
|
+
def recall(self, bank_id, query, **kwargs):
|
|
105
|
+
self.calls.append({"bank_id": bank_id, "query": query, **kwargs})
|
|
106
|
+
return {"results": [dict(r) for r in self._results]}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _Harness(unittest.TestCase):
|
|
110
|
+
def setUp(self):
|
|
111
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-shaping-test-")
|
|
112
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
113
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
114
|
+
|
|
115
|
+
def tearDown(self):
|
|
116
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
117
|
+
if self._prev is None:
|
|
118
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
119
|
+
else:
|
|
120
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
121
|
+
|
|
122
|
+
def _write_transcript(self):
|
|
123
|
+
path = os.path.join(self._tmpdir, "transcript.jsonl")
|
|
124
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
125
|
+
f.write("\n".join([
|
|
126
|
+
_nested_line("user", PRIOR_USER),
|
|
127
|
+
_nested_line("assistant", PRIOR_ASSISTANT),
|
|
128
|
+
_nested_line("user", LATEST),
|
|
129
|
+
]) + "\n")
|
|
130
|
+
return path
|
|
131
|
+
|
|
132
|
+
def _run(self, client, config_extra=None, prompt=LATEST, with_transcript=True):
|
|
133
|
+
hook_input = {
|
|
134
|
+
"prompt": prompt,
|
|
135
|
+
"session_id": "test-session",
|
|
136
|
+
"transcript_path": self._write_transcript() if with_transcript else "",
|
|
137
|
+
"cwd": "/tmp",
|
|
138
|
+
}
|
|
139
|
+
config = {
|
|
140
|
+
"autoRecall": True,
|
|
141
|
+
"bankId": "own-bank",
|
|
142
|
+
"recallMaxTokens": 1024,
|
|
143
|
+
"recallBudget": "low",
|
|
144
|
+
# 2 turns is the fleet default and the shape that produced the bug.
|
|
145
|
+
"recallContextTurns": 2,
|
|
146
|
+
"recallMaxQueryChars": 800,
|
|
147
|
+
"recallPromptPreamble": "",
|
|
148
|
+
"recallParallelDeadlineSeconds": 5,
|
|
149
|
+
"recallTranscriptFallback": False,
|
|
150
|
+
}
|
|
151
|
+
if config_extra:
|
|
152
|
+
config.update(config_extra)
|
|
153
|
+
stdout, stderr = io.StringIO(), io.StringIO()
|
|
154
|
+
with patch.object(recall, "load_config", return_value=config), patch.object(
|
|
155
|
+
recall, "get_api_url", return_value="http://localhost:18888"
|
|
156
|
+
), patch.object(recall, "HindsightClient", return_value=client), patch.object(
|
|
157
|
+
recall, "ensure_bank_mission", return_value=None
|
|
158
|
+
), patch.object(recall, "write_state", return_value=None), patch(
|
|
159
|
+
"sys.stdin", new=io.StringIO(json.dumps(hook_input))
|
|
160
|
+
), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
|
|
161
|
+
recall.main()
|
|
162
|
+
raw = stdout.getvalue()
|
|
163
|
+
context = None
|
|
164
|
+
if raw.strip():
|
|
165
|
+
context = json.loads(raw)["hookSpecificOutput"]["additionalContext"]
|
|
166
|
+
return context
|
|
167
|
+
|
|
168
|
+
def _wire_terms(self, client):
|
|
169
|
+
self.assertTrue(client.calls, "recall never reached the client")
|
|
170
|
+
return set(tokenize_for_bm25(client.calls[0]["query"]))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class WireQueryIsTermCapped(_Harness):
|
|
174
|
+
def test_defaults_to_24_terms(self):
|
|
175
|
+
client = _Client()
|
|
176
|
+
self._run(client)
|
|
177
|
+
self.assertLessEqual(len(self._wire_terms(client)), 24)
|
|
178
|
+
|
|
179
|
+
def test_cap_is_configurable(self):
|
|
180
|
+
for cap in (6, 12, 40):
|
|
181
|
+
client = _Client()
|
|
182
|
+
self._run(client, config_extra={"recallQueryMaxTokens": cap})
|
|
183
|
+
self.assertLessEqual(
|
|
184
|
+
len(self._wire_terms(client)), cap, f"cap={cap} not honoured"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def test_zero_disables_shaping(self):
|
|
188
|
+
client = _Client()
|
|
189
|
+
self._run(client, config_extra={"recallQueryMaxTokens": 0})
|
|
190
|
+
# Rollback lever: the full composed query goes out unshaped.
|
|
191
|
+
self.assertGreater(len(self._wire_terms(client)), 24)
|
|
192
|
+
|
|
193
|
+
def test_latest_turn_survives_the_cap(self):
|
|
194
|
+
# The cap must not cost the user the question they just asked.
|
|
195
|
+
#
|
|
196
|
+
# #3764: this deliberately asserts on ORDINARY English words from the
|
|
197
|
+
# latest turn, not on `v0.19.24`. A version string carries a digit AND
|
|
198
|
+
# is a compound, so it scores 6.0 before recency is considered and
|
|
199
|
+
# survives under every candidate ordering — asserting on it cannot fail
|
|
200
|
+
# on a recency regression. `recall` / `release` / `notes` score 0.0 on
|
|
201
|
+
# shape and are outnumbered by prior-context terms, so they are in the
|
|
202
|
+
# query only because recency is weighted.
|
|
203
|
+
client = _Client()
|
|
204
|
+
self._run(client, config_extra={"recallQueryMaxTokens": 24})
|
|
205
|
+
terms = self._wire_terms(client)
|
|
206
|
+
for word in ("recall", "release", "notes"):
|
|
207
|
+
self.assertIn(word, terms, f"latest-turn term {word!r} lost to prior context")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class RecencyIsAWeightNotATier(_Harness):
|
|
211
|
+
"""#3760 review, Blocker 2. Making recency an ABSOLUTE tier meant any latest
|
|
212
|
+
turn with >= max_tokens surviving terms took every slot, so a conversational
|
|
213
|
+
follow-up whose subject lives only in the prior turn produced a query with
|
|
214
|
+
no subject in it at all — a query about nothing, which BM25-matches a broad
|
|
215
|
+
near-random slice of the bank and embeds to a near-meaningless vector."""
|
|
216
|
+
|
|
217
|
+
# The reviewer's exact reproduction.
|
|
218
|
+
SUBJECT_PRIOR = (
|
|
219
|
+
"We were debugging the Coolify deploy for the webkite container and the "
|
|
220
|
+
"nginx TLS cert."
|
|
221
|
+
)
|
|
222
|
+
SUBJECT_LATEST = (
|
|
223
|
+
"Right, so continuing from where we left off, could you please have another "
|
|
224
|
+
"careful look and tell me whether the thing we were discussing previously is "
|
|
225
|
+
"actually still broken, because honestly the whole situation seems rather "
|
|
226
|
+
"confusing and I would really appreciate a clear explanation of what exactly "
|
|
227
|
+
"is happening underneath and whether anything changed recently."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _write_transcript(self):
|
|
231
|
+
path = os.path.join(self._tmpdir, "transcript.jsonl")
|
|
232
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
233
|
+
f.write("\n".join([
|
|
234
|
+
_nested_line("user", self.SUBJECT_PRIOR),
|
|
235
|
+
_nested_line("user", self.SUBJECT_LATEST),
|
|
236
|
+
]) + "\n")
|
|
237
|
+
return path
|
|
238
|
+
|
|
239
|
+
def test_subject_from_prior_turn_survives_a_long_latest_turn(self):
|
|
240
|
+
client = _Client()
|
|
241
|
+
self._run(client, prompt=self.SUBJECT_LATEST)
|
|
242
|
+
terms = self._wire_terms(client)
|
|
243
|
+
# The latest turn alone yields well over 24 content terms, so under the
|
|
244
|
+
# old absolute tier every one of these was dropped.
|
|
245
|
+
for word in ("coolify", "webkite", "nginx", "tls"):
|
|
246
|
+
self.assertIn(word, terms, f"subject term {word!r} was crowded out")
|
|
247
|
+
|
|
248
|
+
# A prior turn whose subject is ORDINARY ENGLISH — no identifier, no digit,
|
|
249
|
+
# no compound. The recency weight alone cannot save it (every term scores
|
|
250
|
+
# 0.0 against 1.5 for every latest-turn term), so only the reserve can. This
|
|
251
|
+
# is the general form of Blocker 2: with `recallContextTurns: 2` shipped on,
|
|
252
|
+
# any latest turn holding >= max_tokens terms took every slot.
|
|
253
|
+
PLAIN_PRIOR = (
|
|
254
|
+
"The landlord refused to return the bond after the final inspection and "
|
|
255
|
+
"the tribunal hearing was adjourned."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def test_all_english_prior_turn_is_still_represented(self):
|
|
259
|
+
path = os.path.join(self._tmpdir, "transcript.jsonl")
|
|
260
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
261
|
+
f.write("\n".join([
|
|
262
|
+
_nested_line("user", self.PLAIN_PRIOR),
|
|
263
|
+
_nested_line("user", self.SUBJECT_LATEST),
|
|
264
|
+
]) + "\n")
|
|
265
|
+
self._write_transcript = lambda: path
|
|
266
|
+
client = _Client()
|
|
267
|
+
self._run(client, prompt=self.SUBJECT_LATEST)
|
|
268
|
+
terms = self._wire_terms(client)
|
|
269
|
+
prior_terms = set(tokenize_for_bm25(self.PLAIN_PRIOR)) & terms
|
|
270
|
+
self.assertGreaterEqual(
|
|
271
|
+
len(prior_terms),
|
|
272
|
+
24 // 3,
|
|
273
|
+
f"prior context was starved: only {sorted(prior_terms)} survived",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def test_reserve_scales_with_the_cap_and_never_starves_the_latest_turn(self):
|
|
277
|
+
client = _Client()
|
|
278
|
+
self._run(client, prompt=self.SUBJECT_LATEST, config_extra={"recallQueryMaxTokens": 24})
|
|
279
|
+
terms = self._wire_terms(client)
|
|
280
|
+
prior_terms = set(tokenize_for_bm25(self.SUBJECT_PRIOR)) & terms
|
|
281
|
+
latest_terms = set(tokenize_for_bm25(self.SUBJECT_LATEST)) & terms
|
|
282
|
+
# The reserve is a ceiling, not an allocation: prior context is
|
|
283
|
+
# guaranteed representation but must not take the majority of slots.
|
|
284
|
+
self.assertGreaterEqual(len(prior_terms), 4)
|
|
285
|
+
self.assertGreater(len(latest_terms), len(prior_terms))
|
|
286
|
+
|
|
287
|
+
# Two turns of ORDINARY English on both sides, the common conversational
|
|
288
|
+
# case: every candidate scores 0.0 on shape, so neither reserve can decide
|
|
289
|
+
# anything past its own third and the remaining budget is settled purely by
|
|
290
|
+
# the recency weight. Drop `_SCORE_RECENCY` and the tie falls back to
|
|
291
|
+
# `first_seen`, which orders by position in `Prior context: <old> … <new>`
|
|
292
|
+
# — i.e. the STALEST turn wins the leftover budget and the tail of the
|
|
293
|
+
# user's actual question is dropped. This is the fill stage, and it is where
|
|
294
|
+
# recency-as-a-weight (rather than as a tier) does its work.
|
|
295
|
+
FILL_PRIOR = (
|
|
296
|
+
"The removalists arrived before the inspection finished so the landlord "
|
|
297
|
+
"postponed the handover until the following afternoon and the neighbours "
|
|
298
|
+
"complained about the noise in the stairwell again."
|
|
299
|
+
)
|
|
300
|
+
FILL_LATEST = (
|
|
301
|
+
"Could you summarise whether the tribunal accepted the amended evidence "
|
|
302
|
+
"bundle and confirm the hearing date they eventually settled on."
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def test_leftover_budget_goes_to_the_latest_turn_on_equal_merit(self):
|
|
306
|
+
path = os.path.join(self._tmpdir, "transcript.jsonl")
|
|
307
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
308
|
+
f.write("\n".join([
|
|
309
|
+
_nested_line("user", self.FILL_PRIOR),
|
|
310
|
+
_nested_line("user", self.FILL_LATEST),
|
|
311
|
+
]) + "\n")
|
|
312
|
+
self._write_transcript = lambda: path
|
|
313
|
+
client = _Client()
|
|
314
|
+
self._run(client, prompt=self.FILL_LATEST, config_extra={"recallQueryMaxTokens": 24})
|
|
315
|
+
terms = self._wire_terms(client)
|
|
316
|
+
latest_terms = {
|
|
317
|
+
t for t in tokenize_for_bm25(self.FILL_LATEST)
|
|
318
|
+
if len(t) > 1 and t not in BM25_STOPWORDS
|
|
319
|
+
}
|
|
320
|
+
# The latest turn holds more terms than its own reserve (24 // 3 = 8),
|
|
321
|
+
# so the surplus can only be here because the fill stage preferred it.
|
|
322
|
+
self.assertGreater(len(latest_terms), 24 // 3)
|
|
323
|
+
missing = sorted(latest_terms - terms)
|
|
324
|
+
self.assertEqual(
|
|
325
|
+
missing, [], f"the latest turn lost the leftover budget to stale context: {missing}"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class SelectivityRewardsIdentifiersNotLongWords(_Harness):
|
|
330
|
+
"""#3760 review, Blocker 1. The previous revision scored `+min(len, 12)/4`,
|
|
331
|
+
so `understanding` (3.0) and `configuration` (3.0) tied the maximum awarded
|
|
332
|
+
to a compound or a digit while `pkce` (1.0) and `zod` (0.75) sat near the
|
|
333
|
+
floor. Measured median `overlord` df by token length is flat-to-rising, so
|
|
334
|
+
length predicts nothing; English-word membership does."""
|
|
335
|
+
|
|
336
|
+
IDENTIFIER_LATEST = (
|
|
337
|
+
"I have been thinking particularly carefully about this and essentially my "
|
|
338
|
+
"understanding of the currently shipped configuration documentation basically "
|
|
339
|
+
"describes something different from the implementation, so probably the "
|
|
340
|
+
"important information here is that generally, additionally, previously "
|
|
341
|
+
"mentioned considerations regarding authentication mechanisms suggest we "
|
|
342
|
+
"should use PKCE rather than something else entirely"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def test_short_identifier_beats_long_common_english(self):
|
|
346
|
+
client = _Client()
|
|
347
|
+
self._run(
|
|
348
|
+
client,
|
|
349
|
+
prompt=self.IDENTIFIER_LATEST,
|
|
350
|
+
with_transcript=False,
|
|
351
|
+
config_extra={"recallContextTurns": 1, "recallQueryMaxTokens": 6},
|
|
352
|
+
)
|
|
353
|
+
terms = self._wire_terms(client)
|
|
354
|
+
# Six slots for a 60-word turn. `pkce` is the last word of the sentence
|
|
355
|
+
# and the shortest content token in it, so under the old length rule
|
|
356
|
+
# (score 1.0, against 3.0 for every long abstract noun before it) it was
|
|
357
|
+
# dropped. It must now take the first slot on merit.
|
|
358
|
+
self.assertIn("pkce", terms, "the only identifier in the turn was dropped")
|
|
359
|
+
# The long English words are DEMOTED, not dropped — with six slots and
|
|
360
|
+
# nothing more selective competing, some of them legitimately fill the
|
|
361
|
+
# remainder. What must not happen is any of them outranking `pkce`.
|
|
362
|
+
for filler in ("understanding", "particularly", "configuration", "documentation"):
|
|
363
|
+
self.assertLess(
|
|
364
|
+
_selectivity_score(filler),
|
|
365
|
+
_selectivity_score("pkce"),
|
|
366
|
+
f"{filler!r} scores at or above an identifier",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def test_length_is_not_rewarded(self):
|
|
370
|
+
# The regression this guards: `score += min(len(token), 12) / 4.0`.
|
|
371
|
+
# Measured median `overlord` df by token length is flat-to-rising, so a
|
|
372
|
+
# longer common word is not more selective than a shorter one.
|
|
373
|
+
self.assertEqual(
|
|
374
|
+
_selectivity_score("configuration"), _selectivity_score("thing")
|
|
375
|
+
)
|
|
376
|
+
# ...and the same holds for two identifiers of very different length.
|
|
377
|
+
self.assertEqual(_selectivity_score("zod"), _selectivity_score("kubernetes"))
|
|
378
|
+
|
|
379
|
+
def test_compound_shape_is_rewarded_on_top_of_rarity(self):
|
|
380
|
+
# A path/version/module compound is worth MORE than a merely unknown
|
|
381
|
+
# word, and the compound bonus is what supplies that. Measured on the
|
|
382
|
+
# live `overlord` bank, `.`/`/`/`-` joined tokens are the second-most
|
|
383
|
+
# reliable low-df shape after digits — and unlike a bare rare word the
|
|
384
|
+
# server shreds a compound into its fragments too, so one kept compound
|
|
385
|
+
# buys several matching surfaces. `docs/setup` and `wibbleton` are BOTH
|
|
386
|
+
# absent from the dictionary, so the +2.0 rarity bonus cancels and only
|
|
387
|
+
# the compound bonus can separate them.
|
|
388
|
+
self.assertNotIn("docs/setup", common_english_words())
|
|
389
|
+
self.assertNotIn("wibbleton", common_english_words())
|
|
390
|
+
self.assertGreater(
|
|
391
|
+
_selectivity_score("docs/setup"),
|
|
392
|
+
_selectivity_score("wibbleton"),
|
|
393
|
+
"a compound scores no better than an unknown bare word",
|
|
394
|
+
)
|
|
395
|
+
# And it must still beat a compound-shaped token's own fragments, which
|
|
396
|
+
# are ordinary dictionary words on their own.
|
|
397
|
+
self.assertGreater(_selectivity_score("docs/setup"), _selectivity_score("setup"))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class RoleLabelsNeverReachTheWire(_Harness):
|
|
401
|
+
def test_user_and_assistant_are_not_bm25_terms(self):
|
|
402
|
+
client = _Client()
|
|
403
|
+
self._run(client)
|
|
404
|
+
terms = self._wire_terms(client)
|
|
405
|
+
# These two were 50% and 22% of the overlord bank's document frequency.
|
|
406
|
+
self.assertNotIn("user", terms)
|
|
407
|
+
self.assertNotIn("assistant", terms)
|
|
408
|
+
# ...and the composed query's own section header.
|
|
409
|
+
self.assertNotIn("prior", terms)
|
|
410
|
+
self.assertNotIn("context", terms)
|
|
411
|
+
|
|
412
|
+
def test_still_absent_with_shaping_disabled(self):
|
|
413
|
+
# Fix at source: compose_recall_query no longer emits the labels, so
|
|
414
|
+
# even the rollback lever cannot put them back on the wire.
|
|
415
|
+
client = _Client()
|
|
416
|
+
self._run(client, config_extra={"recallQueryMaxTokens": 0})
|
|
417
|
+
terms = self._wire_terms(client)
|
|
418
|
+
self.assertNotIn("user", terms)
|
|
419
|
+
self.assertNotIn("assistant", terms)
|
|
420
|
+
|
|
421
|
+
def test_prior_context_content_still_reaches_the_wire(self):
|
|
422
|
+
# Dropping the labels must not drop the turns they labelled.
|
|
423
|
+
client = _Client()
|
|
424
|
+
self._run(client, config_extra={"recallQueryMaxTokens": 0})
|
|
425
|
+
terms = self._wire_terms(client)
|
|
426
|
+
self.assertIn("reaper", terms)
|
|
427
|
+
self.assertIn("manifest", terms)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class PerBankTimeoutIsConfigurable(_Harness):
|
|
431
|
+
def test_defaults_to_12_seconds(self):
|
|
432
|
+
client = _Client()
|
|
433
|
+
self._run(client)
|
|
434
|
+
self.assertEqual(client.calls[0]["timeout"], 12.0)
|
|
435
|
+
|
|
436
|
+
def test_honours_config(self):
|
|
437
|
+
client = _Client()
|
|
438
|
+
self._run(client, config_extra={"recallRequestTimeoutSeconds": 20})
|
|
439
|
+
self.assertEqual(client.calls[0]["timeout"], 20.0)
|
|
440
|
+
|
|
441
|
+
def test_invalid_value_falls_back_to_default(self):
|
|
442
|
+
for bad in ("banana", None, 0, -3):
|
|
443
|
+
client = _Client()
|
|
444
|
+
self._run(client, config_extra={"recallRequestTimeoutSeconds": bad})
|
|
445
|
+
self.assertEqual(client.calls[0]["timeout"], 12.0, f"bad={bad!r}")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class ShapingDoesNotMoveTheOverlapGate(_Harness):
|
|
449
|
+
"""The `recallMinOverlap` containment gate is |Q ∩ M| / |M|. If it measured
|
|
450
|
+
against the SHAPED query, shrinking Q would shrink every memory's score and
|
|
451
|
+
silently tighten every operator's configured threshold. It must keep
|
|
452
|
+
measuring against the user's real words."""
|
|
453
|
+
|
|
454
|
+
def test_memory_matching_only_stopwords_of_the_prompt(self):
|
|
455
|
+
# "did we decide about the auth flow" — every shared term with the
|
|
456
|
+
# memory below is a stopword that shaping removes. With the gate on the
|
|
457
|
+
# UNSHAPED query the memory clears a 0.5 threshold; against the shaped
|
|
458
|
+
# query it would score 0 and be dropped.
|
|
459
|
+
memory = {"text": "did we decide about the", "type": "fact",
|
|
460
|
+
"mentioned_at": "2026-01-01", "id": "m1"}
|
|
461
|
+
client = _Client(results=[memory])
|
|
462
|
+
context = self._run(
|
|
463
|
+
client,
|
|
464
|
+
prompt="did we decide about the auth flow",
|
|
465
|
+
with_transcript=False,
|
|
466
|
+
config_extra={"recallMinOverlap": 0.5, "recallContextTurns": 1},
|
|
467
|
+
)
|
|
468
|
+
self.assertIsNotNone(context)
|
|
469
|
+
self.assertIn("did we decide about the", context)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
if __name__ == "__main__": # pragma: no cover
|
|
473
|
+
unittest.main()
|