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/__init__.py +7 -0
- thymus/agent.py +157 -0
- thymus/claims.py +75 -0
- thymus/cli.py +221 -0
- thymus/conflicts.py +143 -0
- thymus/cost.py +84 -0
- thymus/embed.py +84 -0
- thymus/gates.py +440 -0
- thymus/guard.py +156 -0
- thymus/harness.py +239 -0
- thymus/haystack.py +83 -0
- thymus/integrations/langchain.py +91 -0
- thymus/integrations/mem0.py +152 -0
- thymus/judge.py +198 -0
- thymus/judgecheck.py +96 -0
- thymus/llm.py +426 -0
- thymus/longmemeval.py +377 -0
- thymus/mcp_server.py +86 -0
- thymus/memory.py +133 -0
- thymus/regrade.py +83 -0
- thymus/report.py +303 -0
- thymus/sample.py +68 -0
- thymus/scenarios.py +183 -0
- thymus_memory-0.1.0.dist-info/METADATA +325 -0
- thymus_memory-0.1.0.dist-info/RECORD +29 -0
- thymus_memory-0.1.0.dist-info/WHEEL +4 -0
- thymus_memory-0.1.0.dist-info/entry_points.txt +8 -0
- thymus_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
- thymus_memory-0.1.0.dist-info/licenses/LICENSE-DATA.md +11 -0
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
|