lean-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.
@@ -0,0 +1,14 @@
1
+ """lean-memory — embedded, local-first agent-memory engine (Phase 0 spine).
2
+
3
+ Public API:
4
+ from lean_memory import Memory
5
+ mem = Memory(root="./data")
6
+ mem.add("user-42", "I work at Acme.")
7
+ hits = mem.search("user-42", "where does the user work?")
8
+ """
9
+
10
+ from .memory import Memory
11
+ from .types import Entity, Episode, Fact, RetrievedFact
12
+
13
+ __all__ = ["Memory", "Episode", "Entity", "Fact", "RetrievedFact"]
14
+ __version__ = "0.1.0" # keep in sync with pyproject.toml [project].version
File without changes
@@ -0,0 +1,51 @@
1
+ """Embedder interface + the Matryoshka truncation helper.
2
+
3
+ Default behaviour (per BET 1, corrected 2026-06): produce one full 768-dim vector,
4
+ plus a 256-dim Matryoshka truncation (256 is the verified retrieval-loss knee; 128
5
+ is a speed-only tier). Truncation is slice-then-L2-renormalize — pure, deterministic,
6
+ no second inference pass.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+
13
+ import numpy as np
14
+
15
+
16
+ def matryoshka_truncate(vec: np.ndarray, dim: int) -> np.ndarray:
17
+ """Slice the first `dim` components and L2-renormalize. Deterministic, no inference.
18
+
19
+ This is exactly the MRL recipe: the model was trained so that prefixes of the
20
+ embedding are themselves valid (renormalized) embeddings.
21
+ """
22
+ head = vec[..., :dim].astype(np.float32)
23
+ norm = np.linalg.norm(head, axis=-1, keepdims=True)
24
+ norm = np.where(norm == 0.0, 1.0, norm)
25
+ return head / norm
26
+
27
+
28
+ class Embedder(ABC):
29
+ """Maps text → a full-dim L2-normalized float32 vector.
30
+
31
+ The store quantizes to int8 for vec0; the embedder works in float32 so callers
32
+ can derive Matryoshka truncations before quantization.
33
+ """
34
+
35
+ #: full embedding dimensionality (e.g. 768 for EmbeddingGemma, 1024 for Qwen3-0.6B)
36
+ dim: int = 768
37
+ #: coarse Matryoshka dim for the two-stage dense arm
38
+ coarse_dim: int = 256
39
+
40
+ @abstractmethod
41
+ def embed(self, texts: list[str]) -> np.ndarray:
42
+ """Return an (N, dim) float32 array of L2-normalized embeddings."""
43
+
44
+ def embed_one(self, text: str) -> np.ndarray:
45
+ return self.embed([text])[0]
46
+
47
+ def embed_with_coarse(self, text: str) -> tuple[np.ndarray, np.ndarray]:
48
+ """Return (full_dim_vec, coarse_dim_vec) for a single text — what add_fact wants."""
49
+ full = self.embed_one(text)
50
+ coarse = matryoshka_truncate(full, self.coarse_dim)
51
+ return full, coarse
@@ -0,0 +1,41 @@
1
+ """Deterministic hash-based embedder — the default test backend.
2
+
3
+ Why this exists: Phase 0 must be runnable offline with no model download and no GPU,
4
+ and the spec's reproducibility goal wants a byte-identical common path. FakeEmbedder
5
+ maps text → a fixed vector via a seeded hash, so the whole pipeline (ingest →
6
+ embed → store → retrieve → rerank) is testable in milliseconds with zero deps.
7
+
8
+ It is NOT semantically meaningful — it only guarantees identical text → identical
9
+ vector, and different text → different vector. Swap in SentenceTransformerEmbedder
10
+ for real retrieval quality.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+
17
+ import numpy as np
18
+
19
+ from .base import Embedder
20
+
21
+
22
+ class FakeEmbedder(Embedder):
23
+ """Seeded-hash embedder. Deterministic across processes and machines."""
24
+
25
+ def __init__(self, dim: int = 768, coarse_dim: int = 256) -> None:
26
+ self.dim = dim
27
+ self.coarse_dim = coarse_dim
28
+
29
+ def _vec(self, text: str) -> np.ndarray:
30
+ # Seed a PRNG from a stable hash of the text → reproducible Gaussian vector.
31
+ digest = hashlib.sha256(text.encode("utf-8")).digest()
32
+ seed = int.from_bytes(digest[:8], "little")
33
+ rng = np.random.default_rng(seed)
34
+ v = rng.standard_normal(self.dim).astype(np.float32)
35
+ n = np.linalg.norm(v)
36
+ return v / (n if n else 1.0)
37
+
38
+ def embed(self, texts: list[str]) -> np.ndarray:
39
+ if not texts:
40
+ return np.empty((0, self.dim), dtype=np.float32)
41
+ return np.stack([self._vec(t) for t in texts])
@@ -0,0 +1,65 @@
1
+ """Real local embedder, lazily loaded. Default model = Qwen3-Embedding-0.6B (ungated).
2
+
3
+ Per BET 1 (verified 2026-06): Qwen3-0.6B is the default — it is an *ungated* HF repo
4
+ and beats EmbeddingGemma on MTEB Retrieval (64.65 vs 62.49), so it needs no license
5
+ accept and gives stronger English-only retrieval out of the box. EmbeddingGemma-300m
6
+ remains available by name (`SentenceTransformerEmbedder("google/embeddinggemma-300m")`)
7
+ for multilingual/cross-lingual use, but that is a *gated* repo (HF license accept
8
+ required). Both load through this one class. Requires the `models` extra
9
+ (sentence-transformers).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+
16
+ from .base import Embedder
17
+
18
+ # Known-good local embedders and their full dims. The bench harness compares these.
19
+ KNOWN_MODELS = {
20
+ "google/embeddinggemma-300m": 768,
21
+ "Qwen/Qwen3-Embedding-0.6B": 1024,
22
+ "BAAI/bge-small-en-v1.5": 384, # speed tier only (per spec, NOT the default)
23
+ }
24
+
25
+
26
+ class SentenceTransformerEmbedder(Embedder):
27
+ """Lazy wrapper over sentence-transformers. Model is fetched on first embed()."""
28
+
29
+ def __init__(
30
+ self,
31
+ model_name: str = "Qwen/Qwen3-Embedding-0.6B",
32
+ coarse_dim: int = 256,
33
+ device: str | None = None,
34
+ ) -> None:
35
+ self.model_name = model_name
36
+ self.coarse_dim = coarse_dim
37
+ self._device = device
38
+ self._model = None
39
+ # dim is known up front for the schema; verified on first load.
40
+ self.dim = KNOWN_MODELS.get(model_name, 768)
41
+
42
+ def _ensure(self):
43
+ if self._model is None:
44
+ try:
45
+ from sentence_transformers import SentenceTransformer
46
+ except ImportError as e: # pragma: no cover - install-path guidance
47
+ raise ImportError(
48
+ "SentenceTransformerEmbedder needs the 'models' extra: "
49
+ "pip install 'lean-memory[models]'"
50
+ ) from e
51
+ self._model = SentenceTransformer(self.model_name, device=self._device)
52
+ self.dim = self._model.get_sentence_embedding_dimension() or self.dim
53
+ return self._model
54
+
55
+ def embed(self, texts: list[str]) -> np.ndarray:
56
+ if not texts:
57
+ return np.empty((0, self.dim), dtype=np.float32)
58
+ model = self._ensure()
59
+ vecs = model.encode(
60
+ texts,
61
+ normalize_embeddings=True,
62
+ convert_to_numpy=True,
63
+ show_progress_bar=False,
64
+ )
65
+ return vecs.astype(np.float32)
File without changes
@@ -0,0 +1,329 @@
1
+ """Cheap-then-escalate contradiction → supersession resolver (spec section 5).
2
+
3
+ When a new fact lands on an existing `(subject_id, predicate)` slot we must decide,
4
+ *without paying for an LLM call on the common path*, whether the new fact:
5
+
6
+ - ``asserts`` — first fact in the slot (nothing to compare against), or
7
+ - ``extends`` — refines/adds detail to the slot WITHOUT contradicting it
8
+ (both rows stay co-valid, `is_latest=1` on both), or
9
+ - ``supersedes`` — contradicts/replaces the slot's object
10
+ (old row gets `valid_to`/`superseded_by`/`is_latest=0`).
11
+
12
+ The pipeline is the spec's Engram-style "cheap-then-escalate" ladder, which exists
13
+ specifically to AVOID a per-fact LLM call (BET 2's cost story dies if every write
14
+ escalates):
15
+
16
+ 1. Slot match — done by the CALLER, which passes `existing_latest_facts` already
17
+ filtered to the same (subject_id, predicate) slot (via
18
+ `store.find_latest_in_slot`). If empty → ``asserts``, no embedder/LLM touched.
19
+ 2. Object-embedding cosine — compare the new object's text to each existing
20
+ object's text using the SAME embedder the store uses (L2-normalized vectors,
21
+ so cosine == dot product). Deterministic given a fixed embedder.
22
+ 3. Subsumption heuristic — high cosine + a clean refinement signal (one object
23
+ text subsumes the other as a token-superset/substring) ⇒ ``extends`` (co-valid);
24
+ low cosine on the same slot ⇒ a clear contradiction ⇒ ``supersedes``.
25
+ 4. LLM adjudication ONLY in the ambiguous middle band (cosine neither clearly
26
+ "same/refinement" nor clearly "different") AND only when an `llm_typer` is
27
+ supplied. With no typer (offline default) we fall back to the SAFER choice:
28
+ `supersedes`, because silently keeping two co-valid contradictory facts in one
29
+ slot corrupts `WHERE is_latest=1` current-state reads, whereas an
30
+ over-eager supersede is recoverable (ADD-only: nothing is deleted).
31
+
32
+ Everything here is pure logic + the embedder; the LLM is optional and injected
33
+ behind the `LLMTyper` protocol, so the whole resolver is deterministic and
34
+ offline-testable exactly like FakeEmbedder / IdentityReranker in Phase 0. A
35
+ deterministic stub that satisfies `LLMTyper` keeps the ambiguous-band path
36
+ testable with zero servers.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import re
42
+ from dataclasses import dataclass
43
+ from typing import Optional, Protocol, Sequence, runtime_checkable
44
+
45
+ import numpy as np
46
+
47
+ from ..embed.base import Embedder
48
+ from ..types import Fact
49
+
50
+ # ── relation taxonomy (spec section 5) ────────────────────────────────────────
51
+ # Only the three *structural* relations are decidable here. `derives` is the
52
+ # inferential, LLM-only edge (is_inference=1) and is emitted by the typing pass,
53
+ # never by contradiction resolution — so it is intentionally absent from this set.
54
+ ASSERTS = "asserts"
55
+ EXTENDS = "extends"
56
+ SUPERSEDES = "supersedes"
57
+
58
+ #: Public type for the structural label this resolver can emit.
59
+ RelationLabel = str # one of {ASSERTS, EXTENDS, SUPERSEDES}
60
+
61
+ # ── routing thresholds (the cheap-then-escalate bands) ────────────────────────
62
+ # Cosine over object texts partitions into three regions:
63
+ # sim >= HIGH → near-identical / refinement candidate → cheap subsumption check
64
+ # sim <= LOW → clearly different object on the same slot → contradiction
65
+ # LOW < sim < HIGH → AMBIGUOUS → escalate to the LLM (if any), else default safe
66
+ # Calibrated against the default real embedder (Qwen3-Embedding-0.6B / EmbeddingGemma):
67
+ # distinct short noun-phrase objects on the SAME slot empirically embed at cosine
68
+ # ~0.6–0.95 (they share slot/topic context), so the naive "different object ⇒ low
69
+ # cosine" assumption is FALSE for these models. HIGH marks "essentially the same value
70
+ # (rephrase/refine)"; LOW marks "clearly unrelated value". The wide middle is where the
71
+ # additive-vs-replacement question actually lives — decided by the additive signal below
72
+ # (cheap) then the LLM (only if still ambiguous). Per-embedder tunable via __init__.
73
+ DEFAULT_HIGH_SIM = 0.80
74
+ DEFAULT_LOW_SIM = 0.45
75
+
76
+ _WORD = re.compile(r"[a-z0-9]+")
77
+
78
+ # ── additive-vs-replacement signal (the fix for extends being unreachable) ────────
79
+ # A DIFFERENT object on the same slot is NOT automatically a contradiction: it can be
80
+ # ADDITIVE (co-valid → extends) rather than REPLACING (→ supersedes). Two cheap signals
81
+ # say "additive", deterministically, no LLM:
82
+ # (a) an explicit additive cue in the new fact text ("also", "and", "too", ...), or
83
+ # (b) an inherently MULTI-VALUED predicate (you can like/use/know many things at once),
84
+ # as opposed to a FUNCTIONAL predicate (one current employer / city / birthplace).
85
+ # Without either signal, a different object on a functional slot is a replacement.
86
+ _ADDITIVE_CUE = re.compile(
87
+ r"\b(?:also|too|as\s+well|in\s+addition|additionally|and|plus|another|"
88
+ r"besides|moreover|on\s+the\s+side)\b",
89
+ re.I,
90
+ )
91
+
92
+ #: Predicates that naturally hold MANY co-valid values at once → a new distinct object
93
+ #: extends the slot rather than replacing it. Everything not listed is treated as
94
+ #: FUNCTIONAL (single current value) so a distinct object supersedes.
95
+ _MULTIVALUED_PREDICATES = frozenset(
96
+ {"likes", "dislikes", "uses", "knows", "has", "owns", "speaks", "plays",
97
+ "member_of", "interested_in", "skilled_in"}
98
+ )
99
+
100
+
101
+ @runtime_checkable
102
+ class LLMTyper(Protocol):
103
+ """Pass-4 constrained-typing backend, narrowed to the adjudication this resolver needs.
104
+
105
+ The full Pass-4 typer (Ollama-backed, with a deterministic stub) does more —
106
+ relation typing, coreference, cross-utterance edges — but contradiction
107
+ resolution only needs a single constrained call: *given the new fact and one
108
+ contradicting-candidate existing fact, is this `extends` or `supersedes`?*
109
+ Keeping the surface this small means a trivial stub satisfies it for tests, and
110
+ any richer typer that exposes `adjudicate_contradiction` plugs in unchanged.
111
+
112
+ Implementations MUST return one of {EXTENDS, SUPERSEDES} (never ASSERTS — there
113
+ is, by construction, an existing fact in the slot when we escalate) and MUST be
114
+ callable offline via a deterministic stub (mirrors the Ollama→ConnectionError
115
+ fallback in the typing module: a real typer falls back to its stub, so this
116
+ method never raises a transport error up into the resolver).
117
+ """
118
+
119
+ def adjudicate_contradiction(self, new_fact: Fact, existing_fact: Fact) -> RelationLabel: ...
120
+
121
+
122
+ @dataclass
123
+ class Decision:
124
+ """Outcome of resolution — carries everything `memory.py` needs to act ADD-only.
125
+
126
+ `label`:
127
+ ASSERTS → just write the new fact; touch nothing else.
128
+ EXTENDS → write the new fact; the matched fact stays co-valid (no supersede).
129
+ SUPERSEDES → write the new fact, then `store.supersede_fact(target.id, new.id,
130
+ valid_to=new.valid_at)` on the target.
131
+ `target`:
132
+ The existing fact the new one supersedes (only set for SUPERSEDES; the matched
133
+ fact for EXTENDS is exposed too for logging/links). None for ASSERTS.
134
+ `similarity`:
135
+ Best object cosine in [-1, 1] against the slot (None when no existing fact).
136
+ `route`:
137
+ Which rung of the ladder decided it — 'no_slot' | 'high_extends' |
138
+ 'high_supersedes' | 'low_supersedes' | 'llm' | 'ambiguous_default'. This is
139
+ the observability hook the BET-2 escalation-rate metric reads (route=='llm'
140
+ is the only rung that spent an LLM call).
141
+ """
142
+
143
+ label: RelationLabel
144
+ target: Optional[Fact] = None
145
+ similarity: Optional[float] = None
146
+ route: str = "no_slot"
147
+
148
+ @property
149
+ def escalated(self) -> bool:
150
+ """True iff this decision spent an LLM call — summed by the router's metric."""
151
+ return self.route == "llm"
152
+
153
+
154
+ class ContradictionResolver:
155
+ """Decide asserts/extends/supersedes for a new fact against its slot.
156
+
157
+ Pure + embedder-driven; `llm_typer` optional. Construct once and reuse across
158
+ writes — it is stateless apart from its thresholds.
159
+ """
160
+
161
+ def __init__(
162
+ self,
163
+ *,
164
+ high_sim: float = DEFAULT_HIGH_SIM,
165
+ low_sim: float = DEFAULT_LOW_SIM,
166
+ ) -> None:
167
+ if not (0.0 <= low_sim <= high_sim <= 1.0):
168
+ raise ValueError("require 0 <= low_sim <= high_sim <= 1")
169
+ self.high_sim = high_sim
170
+ self.low_sim = low_sim
171
+
172
+ def classify(
173
+ self,
174
+ new_fact: Fact,
175
+ existing_latest_facts: Sequence[Fact],
176
+ embedder: Embedder,
177
+ *,
178
+ llm_typer: Optional[LLMTyper] = None,
179
+ ) -> Decision:
180
+ """Classify `new_fact` against the (already slot-filtered) `existing_latest_facts`.
181
+
182
+ The caller is responsible for step 1 (slot match): `existing_latest_facts`
183
+ MUST already be the latest facts sharing `new_fact`'s (subject_id, predicate)
184
+ slot. We defensively re-filter by slot and drop exact-text duplicates so a
185
+ loose caller can't make us supersede a fact by an identical restatement.
186
+ """
187
+ # Step 1 — slot match. Defensive re-filter (caller should have done this).
188
+ candidates = [
189
+ f
190
+ for f in existing_latest_facts
191
+ if f.subject_id == new_fact.subject_id
192
+ and f.predicate == new_fact.predicate
193
+ and f.id != new_fact.id
194
+ and f.fact_text != new_fact.fact_text # identical restatement ⇒ not a change
195
+ ]
196
+ if not candidates:
197
+ # Empty slot (or only an identical restatement) → nothing to contradict.
198
+ return Decision(label=ASSERTS, target=None, similarity=None, route="no_slot")
199
+
200
+ # Step 2 — object-embedding cosine. Compare against every slot candidate and
201
+ # resolve against the MOST-similar one (the strongest contradiction/refinement
202
+ # signal). Embeddings are L2-normalized → cosine is a plain dot product.
203
+ new_vec = embedder.embed_one(_object_text(new_fact))
204
+ best: Optional[Fact] = None
205
+ best_sim = -1.0
206
+ for cand in candidates:
207
+ sim = _cosine(new_vec, embedder.embed_one(_object_text(cand)))
208
+ if sim > best_sim:
209
+ best_sim, best = sim, cand
210
+ assert best is not None # candidates is non-empty
211
+
212
+ # Step 3 — cheap subsumption / contradiction by band.
213
+ if best_sim >= self.high_sim:
214
+ # Near-identical object text. Decide refinement vs replacement by a
215
+ # token-subsumption heuristic — no LLM needed for the clear cases.
216
+ if _is_refinement(new_fact, best):
217
+ return Decision(
218
+ label=EXTENDS, target=best, similarity=best_sim, route="high_extends"
219
+ )
220
+ # Same object, no added detail and not equal text ⇒ a restated change
221
+ # (e.g. "moved to Berlin" vs "moved to Munich" that still embed close):
222
+ # treat as a replacement on the slot.
223
+ return Decision(
224
+ label=SUPERSEDES, target=best, similarity=best_sim, route="high_supersedes"
225
+ )
226
+
227
+ if best_sim <= self.low_sim:
228
+ # A clearly DIFFERENT object on the same slot. This is NOT automatically a
229
+ # contradiction: a distinct value can be additive (co-valid → extends) or a
230
+ # replacement (→ supersedes). Decide cheaply by the additive signal.
231
+ if _is_additive(new_fact):
232
+ return Decision(
233
+ label=EXTENDS, target=best, similarity=best_sim, route="low_extends"
234
+ )
235
+ return Decision(
236
+ label=SUPERSEDES, target=best, similarity=best_sim, route="low_supersedes"
237
+ )
238
+
239
+ # Step 4 — ambiguous middle band. A cheap additive cue still resolves it
240
+ # without an LLM (an explicit "also" is unambiguous); otherwise escalate to the
241
+ # LLM ONLY here, and only if one was supplied — the single rung that costs a call.
242
+ if _is_additive(new_fact):
243
+ return Decision(
244
+ label=EXTENDS, target=best, similarity=best_sim, route="mid_extends"
245
+ )
246
+ if llm_typer is not None:
247
+ label = llm_typer.adjudicate_contradiction(new_fact, best)
248
+ label = label if label in (EXTENDS, SUPERSEDES) else SUPERSEDES
249
+ return Decision(label=label, target=best, similarity=best_sim, route="llm")
250
+
251
+ # No typer (offline default): pick the SAFER option. Supersede keeps the slot
252
+ # single-valued so `WHERE is_latest=1` stays correct; nothing is deleted, so
253
+ # an over-eager supersede is fully recoverable from the audit chain.
254
+ return Decision(
255
+ label=SUPERSEDES, target=best, similarity=best_sim, route="ambiguous_default"
256
+ )
257
+
258
+
259
+ # ── helpers ───────────────────────────────────────────────────────────────────
260
+ def _object_text(fact: Fact) -> str:
261
+ """Text we embed/compare for contradiction.
262
+
263
+ Prefer the typed `object_literal` (the parsed slot value — the thing that
264
+ actually contradicts), and fall back to the standalone `fact_text` when the
265
+ object is an entity ref or wasn't separated out. Rules-extracted facts in
266
+ Phase 0 carry no `object_literal`, so the `fact_text` fallback is the common path.
267
+ """
268
+ if fact.object_literal:
269
+ return fact.object_literal
270
+ return fact.fact_text
271
+
272
+
273
+ def _cosine(a: np.ndarray, b: np.ndarray) -> float:
274
+ """Cosine similarity. Embedder vectors are L2-normalized, but renormalize
275
+ defensively so a non-normalizing embedder can't produce out-of-range values."""
276
+ na = float(np.linalg.norm(a))
277
+ nb = float(np.linalg.norm(b))
278
+ if na == 0.0 or nb == 0.0:
279
+ return 0.0
280
+ return float(np.dot(a, b) / (na * nb))
281
+
282
+
283
+ def _tokens(text: str) -> set[str]:
284
+ return set(_WORD.findall(text.lower()))
285
+
286
+
287
+ def _is_refinement(new_fact: Fact, existing: Fact) -> bool:
288
+ """Cheap refinement test: does one object's token set subsume the other's?
289
+
290
+ `extends` means the new fact ADDS detail to the same slot without contradiction.
291
+ The cheapest reliable signal at high cosine is token subsumption: if the new
292
+ object's content words are a strict superset of the existing one's (or vice
293
+ versa), it's adding/dropping qualifiers on the SAME value, not asserting a
294
+ different value — so the two are co-valid. Disjoint-but-similar token sets at
295
+ high cosine are NOT a refinement (handled as a restated change upstream).
296
+ """
297
+ new_toks = _tokens(_object_text(new_fact))
298
+ old_toks = _tokens(_object_text(existing))
299
+ if not new_toks or not old_toks:
300
+ return False
301
+ # Strict superset/subset in either direction → pure add/drop of qualifiers.
302
+ return new_toks > old_toks or old_toks > new_toks
303
+
304
+
305
+ def _is_additive(new_fact: Fact) -> bool:
306
+ """Does this new fact ADD a co-valid value to the slot (→ extends) rather than
307
+ replace the existing one (→ supersedes)?
308
+
309
+ A distinct object on the same slot is additive when EITHER an explicit additive
310
+ cue appears in the fact text ("I *also* use Rust") OR the predicate is inherently
311
+ multi-valued (you can `uses` many tools at once). Functional predicates (works_at,
312
+ lives_in) hold one current value, so a distinct object there is a replacement.
313
+ Deterministic — no LLM. This is what makes multi-valued slots representable.
314
+ """
315
+ if _ADDITIVE_CUE.search(new_fact.fact_text or ""):
316
+ return True
317
+ return new_fact.predicate in _MULTIVALUED_PREDICATES
318
+
319
+
320
+ def escalation_rate(decisions: Sequence[Decision]) -> float:
321
+ """Fraction of decisions that spent an LLM call — the BET-2 first-class metric.
322
+
323
+ Spec target: < 0.20. `memory.py` (or the ablation harness) accumulates the
324
+ Decisions from a batch of writes and reports this; route=='llm' is the only
325
+ rung that escalated. Returns 0.0 for an empty batch.
326
+ """
327
+ if not decisions:
328
+ return 0.0
329
+ return sum(1 for d in decisions if d.escalated) / len(decisions)