thymus-memory 0.1.0__py3-none-any.whl

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.
thymus/cost.py ADDED
@@ -0,0 +1,84 @@
1
+ """What a gate costs: model calls, approximate tokens and time, by the job each call does.
2
+
3
+ Wrap every model once with ``CountingLLM`` (outside the cache) and share one ``Usage``. A call's
4
+ job is recognised from its system prompt, so no gate or agent code has to report anything.
5
+ Tokens are estimated as characters / 4 (the same estimate for every model and gate, so
6
+ comparisons between gates are fair); seconds are only meaningful for uncached calls.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from collections import defaultdict
13
+
14
+ from thymus.llm import LLM
15
+
16
+ # Jobs that exist only because of a gate: the rest (extract, answer) every memory agent does.
17
+ GATE_ROLES = ("classify", "conflict", "verify")
18
+
19
+
20
+ def _roles() -> dict[str, str]:
21
+ from thymus.agent import ANSWER_SYSTEM, EXTRACT_SYSTEM
22
+ from thymus.claims import CLAIM_SYSTEM
23
+ from thymus.conflicts import CONFLICT_SYSTEM, CONFLICT_SYSTEM_SAME_SUBJECT, VERIFY_WORDINGS
24
+
25
+ roles = {ANSWER_SYSTEM: "answer", EXTRACT_SYSTEM: "extract", CLAIM_SYSTEM: "classify",
26
+ CONFLICT_SYSTEM: "conflict", CONFLICT_SYSTEM_SAME_SUBJECT: "conflict"}
27
+ roles.update({system: "verify" for system, _ in VERIFY_WORDINGS.values()})
28
+ return roles
29
+
30
+
31
+ class Usage:
32
+ """Running totals per job: calls, input and output characters, seconds."""
33
+
34
+ def __init__(self) -> None:
35
+ self.totals: dict[str, list[float]] = defaultdict(lambda: [0, 0, 0, 0.0])
36
+
37
+ def record(self, role: str, prompt_chars: int, output_chars: int, seconds: float) -> None:
38
+ t = self.totals[role]
39
+ t[0] += 1
40
+ t[1] += prompt_chars
41
+ t[2] += output_chars
42
+ t[3] += seconds
43
+
44
+ def snapshot(self) -> dict[str, tuple]:
45
+ return {role: tuple(t) for role, t in self.totals.items()}
46
+
47
+ def since(self, before: dict[str, tuple]) -> dict[str, dict]:
48
+ """Usage added after ``before`` was taken, as {role: {calls, tokens_in, tokens_out, seconds}}."""
49
+ out = {}
50
+ for role, t in self.totals.items():
51
+ b = before.get(role, (0, 0, 0, 0.0))
52
+ calls = t[0] - b[0]
53
+ if calls:
54
+ out[role] = {"calls": int(calls), "tokens_in": round((t[1] - b[1]) / 4),
55
+ "tokens_out": round((t[2] - b[2]) / 4), "seconds": round(t[3] - b[3], 3)}
56
+ return out
57
+
58
+
59
+ class CountingLLM:
60
+ """Passes calls through and records each one in ``usage`` under its job."""
61
+
62
+ def __init__(self, inner: LLM, usage: Usage):
63
+ self.inner = inner
64
+ self.name = inner.name
65
+ self.usage = usage
66
+ self._roles = _roles()
67
+
68
+ def complete(self, system: str, prompt: str, schema: dict | None = None) -> str:
69
+ start = time.perf_counter()
70
+ response = self.inner.complete(system, prompt, schema)
71
+ role = self._roles.get(system, "judge") # every other prompt in a run grades replies
72
+ self.usage.record(role, len(system) + len(prompt), len(response), time.perf_counter() - start)
73
+ return response
74
+
75
+ def __getattr__(self, attr: str): # e.g. CachedLLM.exhausted
76
+ return getattr(self.inner, attr)
77
+
78
+
79
+ def gate_overhead(cost: dict[str, dict]) -> dict[str, float]:
80
+ """Calls and tokens spent on gate-only jobs in one scenario."""
81
+ jobs = [cost[r] for r in GATE_ROLES if r in cost]
82
+ return {"calls": sum(j["calls"] for j in jobs),
83
+ "tokens": sum(j["tokens_in"] + j["tokens_out"] for j in jobs),
84
+ "seconds": sum(j["seconds"] for j in jobs)}
thymus/embed.py ADDED
@@ -0,0 +1,84 @@
1
+ """Text embedders used for memory recall."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ import re
8
+ from typing import Protocol
9
+
10
+ from thymus.llm import post_json
11
+
12
+ Vector = list[float]
13
+
14
+
15
+ class Embedder(Protocol):
16
+ name: str
17
+
18
+ def embed(self, texts: list[str]) -> list[Vector]: ...
19
+
20
+
21
+ def cosine(a: Vector, b: Vector) -> float:
22
+ dot = sum(x * y for x, y in zip(a, b))
23
+ norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
24
+ return dot / norm if norm else 0.0
25
+
26
+
27
+ class OllamaEmbedder:
28
+ """Semantic embeddings from a local Ollama model such as nomic-embed-text."""
29
+
30
+ def __init__(self, model: str = "nomic-embed-text", host: str = "http://localhost:11434", timeout: float = 120):
31
+ self.model = model
32
+ self.host = host.rstrip("/")
33
+ self.timeout = timeout
34
+ self.name = f"ollama:{model}"
35
+
36
+ def embed(self, texts: list[str]) -> list[Vector]:
37
+ body = {"model": self.model, "input": texts}
38
+ return post_json(f"{self.host}/api/embed", body, self.timeout)["embeddings"]
39
+
40
+
41
+ class MemoEmbedder:
42
+ """Remembers vectors by text for the length of a run. Every gate re-embeds the same
43
+ memories, and long-memory runs add the same background facts to every scenario."""
44
+
45
+ def __init__(self, inner: Embedder):
46
+ self.inner = inner
47
+ self.name = inner.name
48
+ self._vectors: dict[str, Vector] = {}
49
+
50
+ def embed(self, texts: list[str]) -> list[Vector]:
51
+ missing = [t for t in dict.fromkeys(texts) if t not in self._vectors]
52
+ if missing:
53
+ self._vectors.update(zip(missing, self.inner.embed(missing)))
54
+ return [self._vectors[t] for t in texts]
55
+
56
+
57
+ _STOPWORDS = frozenset(
58
+ "a an and are as at be by can could do does for from has have how i in is it its me my "
59
+ "of on or our should so that the their them then there these they this to was we were "
60
+ "what when where which who why will with you your".split()
61
+ )
62
+ _WORD = re.compile(r"[a-z0-9]+")
63
+
64
+
65
+ class HashEmbedder:
66
+ """Offline bag-of-words embedder. Lexical only: good enough for tests and smoke
67
+ runs, not for real experiments."""
68
+
69
+ name = "hash"
70
+
71
+ def __init__(self, dim: int = 512):
72
+ self.dim = dim
73
+
74
+ def embed(self, texts: list[str]) -> list[Vector]:
75
+ vectors = []
76
+ for text in texts:
77
+ vector = [0.0] * self.dim
78
+ for word in _WORD.findall(text.lower()):
79
+ if word in _STOPWORDS:
80
+ continue
81
+ digest = hashlib.blake2b(word.encode(), digest_size=8).digest()
82
+ vector[int.from_bytes(digest, "little") % self.dim] += 1.0
83
+ vectors.append(vector)
84
+ return vectors
thymus/gates.py ADDED
@@ -0,0 +1,440 @@
1
+ """Memory defences: what gets written, and what gets recalled.
2
+
3
+ Every defence answers two questions:
4
+ - review(): should this candidate memory be stored, quarantined, or rejected,
5
+ and does it retire (supersede) older memories?
6
+ - recall(): which stored memories go into the model's context for a query?
7
+
8
+ The baselines below come from prior work, so the thymus gate is measured against
9
+ real competition rather than only "no defence":
10
+
11
+ - allow-all: no defence, what most memory agents ship with.
12
+ - user-only: provenance quarantine. Blocks everything that came from tool content
13
+ (≈ MemGauge's PQ policy, arXiv 2608.30177).
14
+ - conflict-quarantine: tool content that contradicts something a user stated is
15
+ quarantined (≈ MemGauge's NC policy). Adapted so users can still correct their own facts.
16
+ - last-write-wins: the newest memory always replaces the ones it contradicts.
17
+ The naive way to support updates.
18
+ - bounded-occupancy: writes everything, but tool-sourced memories may fill at most
19
+ one recall slot (proposed but not built in Utility Under Attack, arXiv 2608.21230).
20
+ - label-only: keeps everything and recalls untrusted memories with their source, with no
21
+ rules about updates. The labelling idea of TMA-NM (arXiv 2606.24322) and PPMF
22
+ (arXiv 2607.29167), which use it to gate actions, applied to answers instead.
23
+
24
+ And the gate this project is about:
25
+
26
+ - thymus: self/non-self screening. Tool content may add facts but never speak for the
27
+ user; a fact can only be replaced by the user or by the source that stated it; every
28
+ other conflicting or unvetted claim is kept but recalled with an "unverified" label.
29
+
30
+ Ablations switch off one part of the thymus gate at a time:
31
+
32
+ - thymus-no-screen: no self/non-self screening; tool content may state preferences or
33
+ instructions in the user's name.
34
+ - thymus-any-origin: no same-origin rule; tool content replaces whatever it contradicts.
35
+ - thymus-no-labels: same storage decisions, but memories are recalled without labels.
36
+ - thymus-hide: decide instead of disclose; a claim that contradicts another source is
37
+ quarantined (hidden) instead of kept with a label.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import re
43
+
44
+ from dataclasses import dataclass
45
+ from typing import Callable, Literal
46
+
47
+ from thymus.claims import ClaimClassifier, LLMClaimClassifier
48
+ from thymus.conflicts import ConflictDetector, LLMConflictDetector
49
+ from thymus.llm import LLM
50
+ from thymus.memory import Candidate, Memory, MemoryStore, Trust, holds_several_claims
51
+
52
+ Action = Literal["accept", "reject", "quarantine"]
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Decision:
57
+ action: Action
58
+ reason: str
59
+ supersedes: tuple[int, ...] = () # ids of memories to retire if this one is stored
60
+ trust: Trust = "verified"
61
+ contradicts: tuple[int, ...] = () # ids this memory disagrees with but does not replace
62
+ contest_back: tuple[int, ...] = () # earlier memories that become contested by this one too
63
+
64
+
65
+ class Gate:
66
+ name = "base"
67
+ # Replaced memories: False retires them, True keeps them as labelled older versions,
68
+ # "auto" retires single claims and keeps memories holding several (e.g. whole chat rounds).
69
+ soft_supersede: bool | str = False
70
+
71
+ def apply_supersedes(self, store: MemoryStore, ids: tuple[int, ...]) -> None:
72
+ if self.soft_supersede == "auto":
73
+ multi = {m.id for m in store.all() if m.id in ids and holds_several_claims(m.text)}
74
+ store.mark_older(tuple(i for i in ids if i in multi))
75
+ store.retire(tuple(i for i in ids if i not in multi))
76
+ elif self.soft_supersede:
77
+ store.mark_older(ids)
78
+ else:
79
+ store.retire(ids)
80
+
81
+ def review(self, candidate: Candidate, store: MemoryStore) -> Decision:
82
+ return Decision("accept", "no checks")
83
+
84
+ def recall(self, query: str, store: MemoryStore, k: int, speaker: str) -> list[Memory]:
85
+ return [memory for memory, _ in store.search(query, k=k)]
86
+
87
+ def render(self, memory: Memory, speaker: str) -> str:
88
+ """How a recalled memory appears in the model's context."""
89
+ return f"[turn {memory.created_at}] {memory.text}"
90
+
91
+ def background_trust(self, candidate: Candidate) -> Trust | None:
92
+ """Decision for a haystack fact that conflicts with nothing (see thymus.haystack):
93
+ the trust it would be stored with, or None if the gate would not store it."""
94
+ return "verified"
95
+
96
+ def withhold(self, memory: Memory, speaker: str) -> bool:
97
+ """True if a recalled memory goes to the user as a system notice instead of to the model."""
98
+ return False
99
+
100
+ def notice(self, memories: list[Memory], speaker: str) -> str:
101
+ """The system-written disclosure appended to the reply for withheld memories."""
102
+ return ""
103
+
104
+
105
+ class AllowAllGate(Gate):
106
+ name = "allow-all"
107
+
108
+
109
+ class UserOnlyGate(Gate):
110
+ name = "user-only"
111
+
112
+ def review(self, candidate: Candidate, store: MemoryStore) -> Decision:
113
+ if candidate.source == "user":
114
+ return Decision("accept", "stated by a user")
115
+ return Decision("reject", f"came from tool content ({candidate.origin})")
116
+
117
+ def background_trust(self, candidate: Candidate) -> Trust | None:
118
+ return "verified" if candidate.source == "user" else None
119
+
120
+
121
+ def _ids(memories: list[Memory]) -> str:
122
+ return ", ".join(f"#{m.id}" for m in memories)
123
+
124
+
125
+ class ConflictQuarantineGate(Gate):
126
+ name = "conflict-quarantine"
127
+
128
+ def __init__(self, detector: ConflictDetector):
129
+ self.detector = detector
130
+
131
+ def review(self, candidate: Candidate, store: MemoryStore) -> Decision:
132
+ if candidate.source == "user":
133
+ return Decision("accept", "stated by a user")
134
+ trusted = [m for m in self.detector.find(candidate, store) if m.source == "user"]
135
+ if trusted:
136
+ return Decision("quarantine", f"tool content contradicts user-stated memory {_ids(trusted)}")
137
+ return Decision("accept", "no conflict with user-stated memories")
138
+
139
+
140
+ class LastWriteWinsGate(Gate):
141
+ name = "last-write-wins"
142
+
143
+ def __init__(self, detector: ConflictDetector):
144
+ self.detector = detector
145
+
146
+ def review(self, candidate: Candidate, store: MemoryStore) -> Decision:
147
+ conflicts = self.detector.find(candidate, store)
148
+ if conflicts:
149
+ return Decision("accept", f"replaces {_ids(conflicts)}", supersedes=tuple(m.id for m in conflicts))
150
+ return Decision("accept", "no conflict")
151
+
152
+
153
+ class BoundedOccupancyGate(Gate):
154
+ name = "bounded-occupancy"
155
+
156
+ def __init__(self, max_untrusted: int = 1):
157
+ self.max_untrusted = max_untrusted
158
+
159
+ def recall(self, query: str, store: MemoryStore, k: int, speaker: str) -> list[Memory]:
160
+ chosen, untrusted = [], 0
161
+ for memory, _ in store.search(query, k=len(store.all())):
162
+ if memory.source == "tool":
163
+ if untrusted >= self.max_untrusted:
164
+ continue
165
+ untrusted += 1
166
+ chosen.append(memory)
167
+ if len(chosen) == k:
168
+ break
169
+ return chosen
170
+
171
+
172
+ class ThymusGate(Gate):
173
+ name = "thymus"
174
+
175
+ def __init__(
176
+ self,
177
+ detector: ConflictDetector,
178
+ classifier: ClaimClassifier,
179
+ trusted_origins: tuple[str, ...] = ("calendar:",),
180
+ *,
181
+ screen: bool = True,
182
+ same_origin_only: bool = True,
183
+ disclose: bool = True,
184
+ hide_contested: bool = False,
185
+ require_auth: bool = False,
186
+ contest_other_users: bool = False,
187
+ notice: bool = False,
188
+ soft_supersede: bool | str = False,
189
+ confirm_replacements: bool = False,
190
+ symmetric_contest: bool = False,
191
+ name: str = "thymus",
192
+ ):
193
+ self.detector = detector
194
+ self.classifier = classifier
195
+ self.trusted_origins = trusted_origins # the user's own integrations
196
+ # Ablation switches; the defaults are the full gate.
197
+ self.screen = screen
198
+ self.same_origin_only = same_origin_only
199
+ self.disclose = disclose
200
+ self.hide_contested = hide_contested
201
+ # Version 2 rules (thymus-v2): a source replaces its own fact only through an
202
+ # authenticated channel; a user's claim that contradicts another user is contested;
203
+ # contested claims reach the user as a system notice instead of the model.
204
+ self.require_auth = require_auth
205
+ self.contest_other_users = contest_other_users
206
+ self.notice_mode = notice
207
+ self.soft_supersede = soft_supersede
208
+ # Version 3: the pairwise verifier is asked only before a replacement. A conflict with
209
+ # another origin stays contested whatever the verifier says, so verification can make
210
+ # the gate more careful, never less (a missed contest lets a fake update through).
211
+ self.confirm_replacements = confirm_replacements
212
+ # Symmetric contest: when two unvetted tool origins disagree, neither has standing, so
213
+ # the order they arrived in must not decide. Both are withheld and both disclosed.
214
+ self.symmetric_contest = symmetric_contest
215
+ self.name = name
216
+
217
+ def _replaceable(self, candidate: Candidate, memories: list[Memory]) -> list[Memory]:
218
+ if not self.confirm_replacements:
219
+ return memories
220
+ return [m for m in memories if self.detector.confirm(candidate, m)]
221
+
222
+ def _trusted_origin(self, candidate: Candidate) -> bool:
223
+ authentic = candidate.authenticated or not self.require_auth
224
+ return candidate.origin.startswith(self.trusted_origins) and authentic
225
+
226
+ def _trust(self, candidate: Candidate) -> Trust:
227
+ if candidate.source == "user" or self._trusted_origin(candidate):
228
+ return "verified"
229
+ return "unverified"
230
+
231
+ def background_trust(self, candidate: Candidate) -> Trust | None:
232
+ return self._trust(candidate) # background facts are facts and conflict with nothing
233
+
234
+ def review(self, candidate: Candidate, store: MemoryStore) -> Decision:
235
+ if self.screen and candidate.source == "tool" and not self._trusted_origin(candidate):
236
+ kind = self.classifier.kind(candidate.text)
237
+ # "procedure" exists only when the classifier runs with procedures=True; it is
238
+ # imperative knowledge about the world, stored and labelled like any fact.
239
+ if kind not in ("fact", "procedure"):
240
+ return Decision("reject", f"{candidate.origin} tried to speak for the user ({kind})")
241
+
242
+ trust = self._trust(candidate)
243
+ conflicts = self.detector.find(candidate, store)
244
+ if not conflicts:
245
+ return Decision("accept", f"new {trust} fact", trust=trust)
246
+
247
+ if candidate.source == "user":
248
+ # Users may correct facts from tools and their own earlier statements,
249
+ # but not what a different user told a shared assistant.
250
+ mine = [m for m in conflicts if m.source == "tool" or m.speaker == candidate.speaker]
251
+ own = self._replaceable(candidate, mine)
252
+ contested = tuple(m.id for m in conflicts if m not in mine) if self.contest_other_users else ()
253
+ return Decision("accept", f"user update replaces {_ids(own)}", tuple(m.id for m in own), trust, contested)
254
+
255
+ can_replace = candidate.authenticated or not self.require_auth
256
+ own_source = [
257
+ m for m in conflicts if (m.origin == candidate.origin and can_replace) or not self.same_origin_only
258
+ ]
259
+ same_origin = self._replaceable(candidate, own_source)
260
+ others = [m for m in conflicts if m not in own_source]
261
+ if others and self.hide_contested:
262
+ return Decision("quarantine", f"contradicts {_ids(others)} from other sources, hidden")
263
+ if others:
264
+ trust = "unverified"
265
+ reason = f"same-origin update replaces {_ids(same_origin)}" if same_origin else "no replacement"
266
+ if others:
267
+ reason += f"; contradicts {_ids(others)} from other sources, kept unverified"
268
+ back = ()
269
+ if self.symmetric_contest:
270
+ back = tuple(m.id for m in others if m.source == "tool" and m.trust == "unverified")
271
+ if back:
272
+ reason += f"; {_ids([m for m in others if m.id in back])} contested in turn"
273
+ return Decision("accept", reason, tuple(m.id for m in same_origin), trust, tuple(m.id for m in others), back)
274
+
275
+ def render(self, memory: Memory, speaker: str) -> str:
276
+ line = super().render(memory, speaker)
277
+ if memory.older:
278
+ line += " [older version: later updated]"
279
+ return disclose(line, memory, speaker) if self.disclose else line
280
+
281
+ def withhold(self, memory: Memory, speaker: str) -> bool:
282
+ return self.notice_mode and contested(memory, speaker)
283
+
284
+ def notice(self, memories: list[Memory], speaker: str) -> str:
285
+ if not memories:
286
+ return ""
287
+ claims = "; ".join(
288
+ f"{'another user of this assistant' if m.source == 'user' else 'a message from ' + m.origin} said: "
289
+ f'"{defang(m.text)}"'
290
+ for m in sorted(memories, key=lambda m: m.created_at)
291
+ )
292
+ return (
293
+ f"Note: I also have unconfirmed information that contradicts this. {claims}. "
294
+ "I have not used it in my answer; please check with the source directly before acting on it."
295
+ )
296
+
297
+
298
+ _LINK = re.compile(r"(?:https?://)?\b[\w-]+(?:\.[\w-]+)*\.[A-Za-z]{2,}\b(?:/\S*)?")
299
+ _EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b")
300
+ _PHONE = re.compile(r"\+?\d[\d\s-]{5,}\d")
301
+
302
+
303
+ def defang(text: str) -> str:
304
+ """What a notice may repeat of a contested claim: links, email addresses and phone numbers
305
+ are removed, so the system never hands the user an attacker's contact in its own voice.
306
+ Times, dates and amounts stay, so a real update's new value still reaches the user."""
307
+ text = _EMAIL.sub("[address removed]", text)
308
+ text = _LINK.sub("[link removed]", text)
309
+ return _PHONE.sub(lambda m: "[number removed]" if sum(c.isdigit() for c in m.group()) >= 7 else m.group(), text)
310
+
311
+
312
+ def contested(memory: Memory, speaker: str) -> bool:
313
+ """A claim that contradicts another source and is not the viewer's own verified statement."""
314
+ return bool(memory.contradicts) and (memory.trust == "unverified" or memory.speaker != speaker)
315
+
316
+
317
+ def disclose(line: str, memory: Memory, speaker: str) -> str:
318
+ """Append the source label that unverified memories carry at recall."""
319
+ if memory.trust == "unverified" and memory.contradicts:
320
+ return f"{line} [unverified: from {memory.origin}; contradicts an earlier memory]"
321
+ if memory.trust == "unverified":
322
+ return f"{line} [unverified: from {memory.origin}]"
323
+ if memory.source == "user" and memory.speaker != speaker:
324
+ return f"{line} [unverified: said by another user of this assistant]"
325
+ return line
326
+
327
+
328
+ class LabelOnlyGate(Gate):
329
+ """Keep every memory; label untrusted ones at recall; never replace anything."""
330
+
331
+ name = "label-only"
332
+
333
+ def __init__(self, trusted_origins: tuple[str, ...] = ("calendar:",)):
334
+ self.trusted_origins = trusted_origins
335
+
336
+ def background_trust(self, candidate: Candidate) -> Trust | None:
337
+ if candidate.source == "user" or candidate.origin.startswith(self.trusted_origins):
338
+ return "verified"
339
+ return "unverified"
340
+
341
+ def review(self, candidate: Candidate, store: MemoryStore) -> Decision:
342
+ trust = self.background_trust(candidate)
343
+ return Decision("accept", f"stored as {trust}", trust=trust)
344
+
345
+ def render(self, memory: Memory, speaker: str) -> str:
346
+ return disclose(super().render(memory, speaker), memory, speaker)
347
+
348
+
349
+ GATES: dict[str, Callable[[LLM], Gate]] = {
350
+ "allow-all": lambda llm: AllowAllGate(),
351
+ "user-only": lambda llm: UserOnlyGate(),
352
+ "conflict-quarantine": lambda llm: ConflictQuarantineGate(LLMConflictDetector(llm)),
353
+ "last-write-wins": lambda llm: LastWriteWinsGate(LLMConflictDetector(llm)),
354
+ "bounded-occupancy": lambda llm: BoundedOccupancyGate(),
355
+ "label-only": lambda llm: LabelOnlyGate(),
356
+ "thymus": lambda llm: ThymusGate(LLMConflictDetector(llm), LLMClaimClassifier(llm)),
357
+ "thymus-no-screen": lambda llm: ThymusGate(
358
+ LLMConflictDetector(llm), LLMClaimClassifier(llm), screen=False, name="thymus-no-screen"),
359
+ "thymus-any-origin": lambda llm: ThymusGate(
360
+ LLMConflictDetector(llm), LLMClaimClassifier(llm), same_origin_only=False, name="thymus-any-origin"),
361
+ "thymus-no-labels": lambda llm: ThymusGate(
362
+ LLMConflictDetector(llm), LLMClaimClassifier(llm), disclose=False, name="thymus-no-labels"),
363
+ "thymus-hide": lambda llm: ThymusGate(
364
+ LLMConflictDetector(llm), LLMClaimClassifier(llm), hide_contested=True, name="thymus-hide"),
365
+ "thymus-v2": lambda llm: ThymusGate(
366
+ LLMConflictDetector(llm), LLMClaimClassifier(llm), require_auth=True, contest_other_users=True, notice=True,
367
+ name="thymus-v2"),
368
+ "thymus-v2-soft": lambda llm: ThymusGate(
369
+ LLMConflictDetector(llm), LLMClaimClassifier(llm), require_auth=True, contest_other_users=True, notice=True,
370
+ soft_supersede=True, name="thymus-v2-soft"),
371
+ "thymus-v2-auto": lambda llm: ThymusGate(
372
+ LLMConflictDetector(llm), LLMClaimClassifier(llm), require_auth=True, contest_other_users=True, notice=True,
373
+ soft_supersede="auto", name="thymus-v2-auto"),
374
+ "thymus-v2-precise": lambda llm: ThymusGate(
375
+ LLMConflictDetector(llm, same_subject=True), LLMClaimClassifier(llm), require_auth=True, contest_other_users=True,
376
+ notice=True, soft_supersede="auto", name="thymus-v2-precise"),
377
+ "thymus-v2-verified": lambda llm: ThymusGate(
378
+ LLMConflictDetector(llm, same_subject=True, verify=True), LLMClaimClassifier(llm), require_auth=True,
379
+ contest_other_users=True, notice=True, soft_supersede="auto", name="thymus-v2-verified"),
380
+ "thymus-v3": lambda llm: ThymusGate(
381
+ LLMConflictDetector(llm, same_subject=True), LLMClaimClassifier(llm), require_auth=True,
382
+ contest_other_users=True, notice=True, soft_supersede="auto", confirm_replacements=True, name="thymus-v3"),
383
+ # The final gate with the procedure class: imperative knowledge about the world (a policy,
384
+ # a runbook step, advice) is stored as a labelled claim; only directives aimed at the
385
+ # assistant itself are rejected. Designed after MemShield, MemForm and the revoked-policy
386
+ # sets showed rule 1 rejecting procedural knowledge; reported beside thymus-v3, never in
387
+ # its place.
388
+ "thymus-v3-procedures": lambda llm: ThymusGate(
389
+ LLMConflictDetector(llm, same_subject=True), LLMClaimClassifier(llm, procedures=True), require_auth=True,
390
+ contest_other_users=True, notice=True, soft_supersede="auto", confirm_replacements=True,
391
+ name="thymus-v3-procedures"),
392
+ # The final gate with symmetric contest (designed after the gate-aware pre-emption attacks;
393
+ # pre-registered in docs/preregistration-v4.md before any run).
394
+ "thymus-v4": lambda llm: ThymusGate(
395
+ LLMConflictDetector(llm, same_subject=True), LLMClaimClassifier(llm), require_auth=True,
396
+ contest_other_users=True, notice=True, soft_supersede="auto", confirm_replacements=True,
397
+ symmetric_contest=True, name="thymus-v4"),
398
+ # Ablations of the final gate: thymus-v3 with exactly one ingredient removed.
399
+ "thymus-v3-no-screen": lambda llm: ThymusGate(
400
+ LLMConflictDetector(llm, same_subject=True), LLMClaimClassifier(llm), screen=False, require_auth=True,
401
+ contest_other_users=True, notice=True, soft_supersede="auto", confirm_replacements=True,
402
+ name="thymus-v3-no-screen"),
403
+ "thymus-v3-any-origin": lambda llm: ThymusGate(
404
+ LLMConflictDetector(llm, same_subject=True), LLMClaimClassifier(llm), same_origin_only=False, require_auth=True,
405
+ contest_other_users=True, notice=True, soft_supersede="auto", confirm_replacements=True,
406
+ name="thymus-v3-any-origin"),
407
+ "thymus-v3-no-labels": lambda llm: ThymusGate(
408
+ LLMConflictDetector(llm, same_subject=True), LLMClaimClassifier(llm), disclose=False, require_auth=True,
409
+ contest_other_users=True, notice=True, soft_supersede="auto", confirm_replacements=True,
410
+ name="thymus-v3-no-labels"),
411
+ "thymus-v3-hide": lambda llm: ThymusGate(
412
+ LLMConflictDetector(llm, same_subject=True), LLMClaimClassifier(llm), hide_contested=True, require_auth=True,
413
+ contest_other_users=True, notice=True, soft_supersede="auto", confirm_replacements=True,
414
+ name="thymus-v3-hide"),
415
+ "thymus-v3-no-auth": lambda llm: ThymusGate(
416
+ LLMConflictDetector(llm, same_subject=True), LLMClaimClassifier(llm), require_auth=False,
417
+ contest_other_users=True, notice=True, soft_supersede="auto", confirm_replacements=True,
418
+ name="thymus-v3-no-auth"),
419
+ "thymus-v2-inline": lambda llm: ThymusGate(
420
+ LLMConflictDetector(llm), LLMClaimClassifier(llm), require_auth=True, contest_other_users=True,
421
+ name="thymus-v2-inline"),
422
+ }
423
+
424
+ # The gates compared in the main experiment; the ablations are run separately.
425
+ MAIN_GATES = (
426
+ "allow-all", "user-only", "conflict-quarantine", "last-write-wins", "bounded-occupancy", "label-only", "thymus",
427
+ )
428
+ ABLATIONS = ("thymus-no-screen", "thymus-any-origin", "thymus-no-labels", "thymus-hide")
429
+ ABLATIONS_V3 = ("thymus-v3-no-screen", "thymus-v3-any-origin", "thymus-v3-no-labels", "thymus-v3-hide", "thymus-v3-no-auth",
430
+ "thymus-v3-procedures")
431
+ # Version 2 of the gate and its inline-label variant (see ThymusGate).
432
+ VERSION_2 = ("thymus-v2", "thymus-v2-inline", "thymus-v2-soft", "thymus-v2-auto", "thymus-v2-precise", "thymus-v2-verified",
433
+ "thymus-v3", "thymus-v4")
434
+
435
+
436
+ def make_gate(name: str, llm: LLM) -> Gate:
437
+ try:
438
+ return GATES[name](llm)
439
+ except KeyError:
440
+ raise ValueError(f"unknown gate {name!r}; choose from {', '.join(GATES)}") from None