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,244 @@
1
+ """Relation taxonomy + the pre-typing extraction `Candidate` — shared by Pass 2-4.
2
+
3
+ This module is the small, dependency-light vocabulary that the Phase 1 hybrid
4
+ extractor speaks. It pins down two things the spec (section 5) leans on:
5
+
6
+ 1. The **four-relation taxonomy** — `asserts | supersedes | extends | derives` —
7
+ our improvement over supermemory's `updates|extends|derives`. These are
8
+ *structural* edge types (how a new fact relates to an existing `(subject,
9
+ predicate)` slot), NOT domain predicates like `works_at`. The domain predicate
10
+ lives on `Fact.predicate`; the taxonomy relation governs *versioning behaviour*.
11
+
12
+ - `asserts` — a new fact about a slot (no prior fact, or unrelated). Default.
13
+ - `supersedes` — new fact contradicts/replaces the object of an existing slot.
14
+ Triggers versioning: old.valid_to / superseded_by / is_latest=0.
15
+ - `extends` — new fact adds detail to the same slot WITHOUT contradiction
16
+ (co-valid; both rows stay is_latest=1).
17
+ - `derives` — inferential, cross-utterance. `is_inference=1`, and per the
18
+ spec it is **LLM-only**: rules/GLiNER2 surface-form passes can
19
+ never emit it, only the Pass 4 constrained-typing step can.
20
+
21
+ 2. The **`Candidate`** — the over-generated extraction unit BEFORE routing/typing.
22
+ It is the shared currency between Pass 2 (GLiNER2 generates many at high recall),
23
+ Pass 3 (the recall-biased router flags `needs_typing`), and Pass 4 (the LLM
24
+ assigns a taxonomy relation + `is_inference` + resolves coreference). It is
25
+ deliberately looser than `ExtractedFact`/`Fact`: predicate and object may be
26
+ missing/guessed, and `needs_typing` records the router's escalation decision.
27
+
28
+ Kept to stdlib + dataclasses on purpose: this is imported by the rules pass (no
29
+ heavy deps), the GLiNER2 pass, the router, and the LLM-typing pass, so it must stay
30
+ import-clean and offline-testable — no torch, no ollama, no model download here.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import dataclass, field
36
+ from enum import Enum
37
+ from typing import Optional
38
+
39
+
40
+ class Relation(str, Enum):
41
+ """The four structural relations a candidate can hold against an existing slot.
42
+
43
+ Subclasses `str` so the value round-trips through SQLite / JSON as a plain
44
+ string (e.g. stored as a column, or emitted by the LLM-typing schema) while
45
+ still giving us a closed enum to validate against. `Relation("supersedes")`
46
+ and `Relation.SUPERSEDES == "supersedes"` both work.
47
+ """
48
+
49
+ ASSERTS = "asserts"
50
+ SUPERSEDES = "supersedes"
51
+ EXTENDS = "extends"
52
+ DERIVES = "derives"
53
+
54
+
55
+ #: The relation assigned when nothing else applies (new fact, fresh slot). Used as
56
+ #: the deterministic default so the rules/GLiNER2 passes can label candidates
57
+ #: without an LLM, and the router only escalates the genuinely ambiguous ones.
58
+ DEFAULT_RELATION = Relation.ASSERTS
59
+
60
+ #: Relations the surface-form (deterministic) passes are allowed to emit on their
61
+ #: own. `derives` is excluded: it is inferential and, per spec section 5, only the
62
+ #: LLM-typing pass may produce it. The router uses this to force escalation of any
63
+ #: candidate that looks like a possible `derives`.
64
+ DETERMINISTIC_RELATIONS: frozenset[Relation] = frozenset(
65
+ {Relation.ASSERTS, Relation.SUPERSEDES, Relation.EXTENDS}
66
+ )
67
+
68
+ #: Relations that the LLM-typing pass (Pass 4) may assign. It is the full taxonomy:
69
+ #: the LLM both validates the cheap deterministic verdicts AND is the *only* source
70
+ #: of `derives`.
71
+ LLM_TYPED_RELATIONS: frozenset[Relation] = frozenset(Relation)
72
+
73
+ # ── compatibility aliases (single source of truth for sibling passes) ──
74
+ # llm_typer.py imports these names; export them here so there is ONE taxonomy and
75
+ # the relation list cannot drift between modules.
76
+ #: Tuple of relation string values, in taxonomy order.
77
+ RELATIONS: tuple[str, ...] = tuple(r.value for r in Relation)
78
+
79
+ #: Word-boundary inference cue tokens shared by the router (Pass 3) and the
80
+ #: StubTyper (Pass 4) so the two deterministic passes AGREE on what looks
81
+ #: inferential. Used with a \\b...\\b regex, never bare substring membership
82
+ #: (substring matching gave false positives like 'so' inside 'also').
83
+ INFERENCE_CUES: tuple[str, ...] = (
84
+ "so", "therefore", "thus", "hence", "because", "since", "must",
85
+ "probably", "likely", "means", "consequently", "implies", "suggests",
86
+ )
87
+
88
+
89
+ def is_inference_relation(value) -> bool:
90
+ """True if the (string or Relation) names the inferential `derives` relation.
91
+
92
+ Compatibility helper imported by llm_typer.py; defers to `relation_from_str`
93
+ so messy LLM output ('Derives', ' derives ') still resolves correctly.
94
+ """
95
+ return relation_from_str(value) is Relation.DERIVES
96
+
97
+
98
+ def relation_from_str(value: Optional[str], *, default: Relation = DEFAULT_RELATION) -> Relation:
99
+ """Coerce a (possibly None / LLM-emitted / messy) string into a `Relation`.
100
+
101
+ Forgiving on input so the LLM-typing pass can hand us whatever its constrained
102
+ schema produced without crashing the pipeline; falls back to `default` on
103
+ anything unrecognized. Case- and whitespace-insensitive.
104
+ """
105
+ if isinstance(value, Relation):
106
+ return value
107
+ if value is None:
108
+ return default
109
+ key = value.strip().lower()
110
+ for rel in Relation:
111
+ if rel.value == key:
112
+ return rel
113
+ return default
114
+
115
+
116
+ def is_inference_flag(relation: Relation) -> int:
117
+ """Map a taxonomy relation → the `Fact.is_inference` column value (0/1).
118
+
119
+ Only `derives` is inferential. Returns an int (not bool) to match the schema's
120
+ `is_inference INTEGER` column and `Fact.is_inference: int` field exactly.
121
+ """
122
+ return 1 if relation is Relation.DERIVES else 0
123
+
124
+
125
+ def is_llm_only(relation: Relation) -> bool:
126
+ """True if only the LLM-typing pass may emit this relation (currently `derives`).
127
+
128
+ The router consults this (via `DETERMINISTIC_RELATIONS`) to guarantee a
129
+ deterministic pass never ships an inferential edge unreviewed.
130
+ """
131
+ return relation not in DETERMINISTIC_RELATIONS
132
+
133
+
134
+ def triggers_versioning(relation: Relation) -> bool:
135
+ """True if assigning this relation must run the supersession path (Pass 5).
136
+
137
+ Only `supersedes` flips the old slot fact's `valid_to`/`superseded_by`/
138
+ `is_latest`. `asserts`/`extends` are co-valid (nothing retired); `derives` adds
139
+ a new inferential row but does not retire the surface facts it was derived from.
140
+ """
141
+ return relation is Relation.SUPERSEDES
142
+
143
+
144
+ # ── predicate-slot helpers ──
145
+ # The versioning "slot" is the (subject_id, predicate) pair — the same key the
146
+ # store indexes (ix_fact_slot) and `find_latest_in_slot` looks up. Centralizing the
147
+ # slot construction here keeps the router, the contradiction check, and the store
148
+ # call agreeing on exactly one canonical key shape.
149
+
150
+ #: Canonical placeholder predicate for a candidate whose relation slot the
151
+ #: deterministic passes could not guess (GLiNER2 gives head/tail spans but the
152
+ #: predicate may be unknown until LLM typing). Router treats this as needs_typing.
153
+ UNTYPED_PREDICATE = "_untyped"
154
+
155
+
156
+ def normalize_predicate(predicate: Optional[str]) -> str:
157
+ """Normalize a raw predicate string into a stable slot token.
158
+
159
+ Lowercase, collapse internal whitespace to single underscores, strip surrounding
160
+ punctuation. Mirrors the rules-pass convention (`works_at`, `lives_in`) so a
161
+ GLiNER2 relation name and a rules predicate collapse to the same slot when they
162
+ mean the same thing. Empty/None → `UNTYPED_PREDICATE`.
163
+ """
164
+ if not predicate:
165
+ return UNTYPED_PREDICATE
166
+ cleaned = predicate.strip().lower().strip("\"'.,!?;:")
167
+ if not cleaned:
168
+ return UNTYPED_PREDICATE
169
+ return "_".join(cleaned.split())
170
+
171
+
172
+ def slot_key(subject_id: str, predicate: Optional[str]) -> tuple[str, str]:
173
+ """The canonical `(subject_id, predicate)` versioning slot key.
174
+
175
+ This is the key for supersession lookup (`store.find_latest_in_slot`) and the
176
+ contradiction → supersession flow. The predicate is normalized so callers never
177
+ have to remember to do it themselves.
178
+ """
179
+ return (subject_id, normalize_predicate(predicate))
180
+
181
+
182
+ @dataclass
183
+ class Candidate:
184
+ """An over-generated extraction candidate BEFORE routing/typing (Pass 2-4 currency).
185
+
186
+ Emitted at high recall by the deterministic passes (rules + GLiNER2). The fields
187
+ are intentionally loose — `predicate`/`object_*` may be absent or only guessed —
188
+ because the whole point of Pass 2 is to over-generate and let Pass 3 (router) and
189
+ Pass 4 (LLM typing) decide which candidates are real and how to type them.
190
+
191
+ Fields:
192
+ subject_name — resolved later to a `subject_id`; the head span / subject text.
193
+ predicate — guessed relation slot, or None if GLiNER2 gave only spans.
194
+ object_name — entity-valued object span (tail), if any (resolved to object_id).
195
+ object_literal — literal-valued object (date/price/string), if any.
196
+ fact_text — the standalone sentence (what eventually gets embedded).
197
+ valid_at — world/event time (epoch ms), resolved against episode.t_ref.
198
+ confidence — generator confidence in [0,1] (GLiNER2 span/relation score, or
199
+ the rules-pass default). The router escalates low-confidence ones.
200
+ source — which generator emitted it: 'rules' | 'gliner2' | 'stub'.
201
+ subject_span — (start, end) char offsets of the head/subject span, if known
202
+ (GLiNER2 reports these; rules may not) — for coref/provenance.
203
+ object_span — (start, end) char offsets of the tail/object span, if known.
204
+ object_id — resolved entity id for the object, once the resolver runs.
205
+ needs_typing — the router's verdict (Pass 3). False until the router sets it;
206
+ True means "escalate to the Pass-4 LLM-typing batch". This is
207
+ the field the escalation-rate metric is computed over.
208
+ escalation_reasons — the router's recorded reasons for escalating (for the
209
+ first-class escalation-rate-by-reason metric and debugging).
210
+ """
211
+
212
+ subject_name: str
213
+ fact_text: str
214
+ valid_at: int
215
+ predicate: Optional[str] = None
216
+ object_name: Optional[str] = None
217
+ object_literal: Optional[str] = None
218
+ object_id: Optional[str] = None
219
+ confidence: float = 0.6
220
+ source: str = "gliner2" # 'rules' | 'gliner2' | 'stub'
221
+ subject_span: Optional[tuple[int, int]] = None
222
+ object_span: Optional[tuple[int, int]] = None
223
+ needs_typing: bool = False
224
+ escalation_reasons: list[str] = field(default_factory=list)
225
+
226
+ # The taxonomy relation, if/when assigned. None until typed (deterministic
227
+ # passes may set a tentative ASSERTS/EXTENDS/SUPERSEDES; Pass 4 may overwrite,
228
+ # and is the only writer of DERIVES). Kept here so the candidate carries the
229
+ # routing result through to the supersession/persistence step.
230
+ relation: Optional[Relation] = None
231
+
232
+ def slot(self, subject_id: str) -> tuple[str, str]:
233
+ """Canonical `(subject_id, predicate)` versioning slot for this candidate."""
234
+ return slot_key(subject_id, self.predicate)
235
+
236
+ @property
237
+ def normalized_predicate(self) -> str:
238
+ """The slot-token form of `predicate` (`UNTYPED_PREDICATE` if unset)."""
239
+ return normalize_predicate(self.predicate)
240
+
241
+ @property
242
+ def is_inference(self) -> int:
243
+ """`Fact.is_inference` value implied by the assigned relation (0 if untyped)."""
244
+ return is_inference_flag(self.relation) if self.relation is not None else 0
@@ -0,0 +1,150 @@
1
+ """MCP server exposing lean-memory as agent-memory tools.
2
+
3
+ Run it:
4
+ python -m lean_memory.mcp_server # stdio transport
5
+ lean-memory-mcp # console-script equivalent
6
+
7
+ Wire it into an MCP client (Claude Desktop / Claude Code) with examples/mcp_config.json.
8
+
9
+ Backends: if the `models` extra is installed (sentence-transformers importable), the
10
+ server uses the real SentenceTransformerEmbedder + CrossEncoderReranker for quality;
11
+ otherwise it falls back to lean-memory's offline stub defaults so it always runs.
12
+
13
+ Data root: LM_DATA_ROOT env var (default ~/.lean_memory). Each namespace is an
14
+ isolated SQLite file under that root (BET 4).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ from mcp.server.fastmcp import FastMCP
25
+
26
+ from .memory import _SAFE_NS, Memory
27
+
28
+
29
+ def _data_root() -> Path:
30
+ root = os.environ.get("LM_DATA_ROOT", "~/.lean_memory")
31
+ return Path(root).expanduser()
32
+
33
+
34
+ def _build_memory(root: Path) -> Memory:
35
+ """Opportunistically upgrade each backend when its optional extra is present.
36
+
37
+ Two INDEPENDENT upgrades, each guarded by its own import so either can succeed
38
+ without the other (`[models]` without `[extract]`, or vice versa):
39
+ * `models` (sentence_transformers) → real embedder + reranker for retrieval.
40
+ * `extract` (gliner2) → Gliner2Generator for real extraction.
41
+ Anything not installed falls back to lean-memory's deterministic offline stubs,
42
+ so the server always runs. GLiNER matters especially here: the frozen escalation/
43
+ granularity constants were calibrated ON the GLiNER path, so shipping it aligns
44
+ the canonical install with what the engine was tuned against.
45
+
46
+ Each successful upgrade logs ONE line to stderr (stdout is the MCP protocol
47
+ channel — NEVER print to stdout here) so cold-cache users, who wait through a
48
+ ~2 GB download on first tool call, see progress in their client's server logs.
49
+ """
50
+ kwargs: dict = {}
51
+
52
+ try:
53
+ import sentence_transformers # noqa: F401
54
+
55
+ from .embed.sentence_transformer import SentenceTransformerEmbedder
56
+ from .retrieve.rerank import CrossEncoderReranker
57
+
58
+ kwargs["embedder"] = SentenceTransformerEmbedder()
59
+ kwargs["reranker"] = CrossEncoderReranker()
60
+ print("[lean-memory] models extra active: real embedder + reranker", file=sys.stderr)
61
+ except ImportError:
62
+ # `models` extra not installed — keep the deterministic offline stub backends.
63
+ pass
64
+
65
+ try:
66
+ import gliner2 # noqa: F401
67
+
68
+ from .extract.gliner_extractor import Gliner2Generator
69
+
70
+ kwargs["generator"] = Gliner2Generator()
71
+ print("[lean-memory] extract extra active: GLiNER2 extraction", file=sys.stderr)
72
+ except ImportError:
73
+ # `extract` extra not installed — keep the stub candidate generator.
74
+ pass
75
+
76
+ return Memory(root=root, **kwargs)
77
+
78
+
79
+ mcp = FastMCP("lean-memory")
80
+
81
+ # Lazy, build-once Memory. Import-time construction would block an MCP client's
82
+ # server spawn through a ~2 GB cold-cache model download with no handshake; the
83
+ # first tool call pays that cost instead, so the handshake is immediate. A plain
84
+ # check-and-build is thread-safe enough for MCP's single-process stdio transport
85
+ # (tool calls are serialized on one event loop; no concurrent _mem() races).
86
+ _MEM: Optional[Memory] = None
87
+
88
+
89
+ def _mem() -> Memory:
90
+ """Return the module-level Memory, building it (once) on first use."""
91
+ global _MEM
92
+ if _MEM is None:
93
+ _MEM = _build_memory(_data_root())
94
+ return _MEM
95
+
96
+
97
+ def _namespace_path(namespace: str) -> Path:
98
+ """The SQLite file backing a namespace (mirrors Memory._store's naming)."""
99
+ safe = _SAFE_NS.sub("_", namespace) or "default"
100
+ return _mem().root / f"{safe}.db"
101
+
102
+
103
+ @mcp.tool()
104
+ def memory_add(namespace: str, text: str) -> str:
105
+ """Ingest text into the namespace's memory. Returns how many facts were written."""
106
+ written = _mem().add(namespace, text)
107
+ n = len(written)
108
+ return f"wrote {n} fact{'s' if n != 1 else ''}"
109
+
110
+
111
+ @mcp.tool()
112
+ def memory_search(namespace: str, query: str, k: int = 5) -> str:
113
+ """Search a namespace's memory and return the top-k facts as a bulleted list."""
114
+ hits = _mem().search(namespace, query, k=k)
115
+ if not hits:
116
+ return "No facts found."
117
+ # De-duplicate by exact fact_text, keeping the highest-ranked instance and
118
+ # preserving order: GLiNER over-generation can surface the same sentence
119
+ # multiple times in top-k, which would print duplicate bullets.
120
+ seen: set[str] = set()
121
+ bullets = []
122
+ for h in hits:
123
+ text = h.fact.fact_text
124
+ if text in seen:
125
+ continue
126
+ seen.add(text)
127
+ bullets.append(f"- {text}")
128
+ return "\n".join(bullets)
129
+
130
+
131
+ @mcp.tool()
132
+ def memory_clear(namespace: str) -> str:
133
+ """Delete all memory for a namespace by removing its SQLite file. Irreversible."""
134
+ # Release any cached open connection so the file handle is freed before unlink.
135
+ store = _mem()._stores.pop(namespace, None)
136
+ if store is not None:
137
+ store.close()
138
+ path = _namespace_path(namespace)
139
+ for p in (path, path.with_suffix(".db-wal"), path.with_suffix(".db-shm")):
140
+ p.unlink(missing_ok=True)
141
+ return f"cleared namespace '{namespace}'"
142
+
143
+
144
+ def main() -> None:
145
+ """Console-script / module entry point: serve over stdio."""
146
+ mcp.run()
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
lean_memory/memory.py ADDED
@@ -0,0 +1,203 @@
1
+ """`Memory` — the top-level facade. This is the public API for Phase 0.
2
+
3
+ mem = Memory(root="./data") # per-tenant files live under root/
4
+ mem.add("ns1", "I work at Acme.", t_ref=...)
5
+ hits = mem.search("ns1", "where does the user work?")
6
+
7
+ Per BET 4, each namespace gets its own SQLite file (write-isolation + brute-force
8
+ comfort). The Memory object owns a small cache of open per-namespace stores.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from .embed.base import Embedder
18
+ from .embed.fake import FakeEmbedder
19
+ from .extract.contradiction import EXTENDS, SUPERSEDES, ContradictionResolver
20
+ from .extract.gliner_extractor import CandidateGenerator, StubCandidateGenerator
21
+ from .extract.llm_typer import StubTyper, TypedFact, Typer
22
+ from .extract.router import RecallBiasedRouter
23
+ from .extract.salience import score_salience
24
+ from .retrieve.rerank import IdentityReranker, Reranker
25
+ from .retrieve.retriever import Retriever
26
+ from .store.sqlite_store import SqliteStore
27
+ from .types import Entity, Episode, Fact, RetrievedFact, new_id, now_ms
28
+
29
+ # domain predicate the rules/stub passes use when none is guessed
30
+ _DEFAULT_PREDICATE = "about"
31
+
32
+ # Cap on the known-entity names handed to the router/typer per add(). The set
33
+ # otherwise grows unboundedly with namespace age (conversational data creates
34
+ # entities every turn), inflating — and eventually silently truncating — the
35
+ # constrained-typing prompt. Most recent names win.
36
+ _KNOWN_ENTITIES_CAP = 100
37
+
38
+ _SAFE_NS = re.compile(r"[^A-Za-z0-9_.-]")
39
+
40
+
41
+ class Memory:
42
+ def __init__(
43
+ self,
44
+ root: str | Path = "./lm_data",
45
+ *,
46
+ embedder: Optional[Embedder] = None,
47
+ reranker: Optional[Reranker] = None,
48
+ generator: Optional[CandidateGenerator] = None,
49
+ router: Optional[RecallBiasedRouter] = None,
50
+ typer: Optional[Typer] = None,
51
+ contradiction: Optional[ContradictionResolver] = None,
52
+ ) -> None:
53
+ self.root = Path(root)
54
+ self.root.mkdir(parents=True, exist_ok=True)
55
+ # Every backend defaults to the OFFLINE stub so the engine runs with zero
56
+ # downloads/servers. Swap in the real ones (SentenceTransformerEmbedder,
57
+ # CrossEncoderReranker, Gliner2Generator, OllamaTyper) for production quality.
58
+ self.embedder = embedder or FakeEmbedder()
59
+ self.reranker = reranker or IdentityReranker()
60
+ # Phase 1 hybrid-extraction pipeline (Pass 2 → 3 → 4 + contradiction):
61
+ self.generator = generator or StubCandidateGenerator()
62
+ self.router = router or RecallBiasedRouter()
63
+ self.typer = typer or StubTyper()
64
+ self.contradiction = contradiction or ContradictionResolver()
65
+ self._stores: dict[str, SqliteStore] = {}
66
+
67
+ # ── per-tenant store management (BET 4: one file per namespace) ──
68
+ def _store(self, namespace: str) -> SqliteStore:
69
+ if namespace not in self._stores:
70
+ safe = _SAFE_NS.sub("_", namespace) or "default"
71
+ path = self.root / f"{safe}.db"
72
+ self._stores[namespace] = SqliteStore(
73
+ path, dim=self.embedder.dim, coarse_dim=self.embedder.coarse_dim
74
+ )
75
+ return self._stores[namespace]
76
+
77
+ # ── ingest (the Phase 1 hybrid pipeline) ──
78
+ def add(
79
+ self, namespace: str, text: str, *, t_ref: Optional[int] = None, source: str = "user"
80
+ ) -> list[str]:
81
+ """Ingest one message through the full hybrid pipeline (spec §5):
82
+
83
+ Pass 2 generate over-generated candidates (rules/GLiNER2, high recall)
84
+ Pass 3 recall-biased router → escalate the hard ones (logs escalation rate)
85
+ Pass 4 LLM constrained typing of the residual; cheap-type the rest
86
+ Pass 5 per fact: resolve entity → contradiction check → ADD-only persist
87
+
88
+ Returns the ids of the facts written. Nothing is ever deleted.
89
+ """
90
+ t_ref = t_ref if t_ref is not None else now_ms()
91
+ store = self._store(namespace)
92
+
93
+ episode = Episode(namespace=namespace, raw=text, t_ref=t_ref, source=source)
94
+ store.add_episode(episode)
95
+
96
+ # Pass 2 — candidate generation (offline default: StubCandidateGenerator).
97
+ candidates = self.generator.generate(episode)
98
+ if not candidates:
99
+ return []
100
+
101
+ # Pass 3 — recall-biased router. known_entities is passed to the router/typer
102
+ # as context only; it no longer drives escalation (the prior_entity trigger was
103
+ # retired 2026-07 — see bench/results/calibration/README.md).
104
+ known = self._known_entity_names(store, namespace)
105
+ to_type, direct = self.router.route(candidates, known_entities=known)
106
+
107
+ # Pass 4 — type the escalated residual with the LLM (stub offline); the direct
108
+ # set is trivially explicit and typed cheaply (asserts, unless an inference cue).
109
+ typed: list[TypedFact] = []
110
+ if to_type:
111
+ typed += self.typer.type_candidates(episode.raw, to_type, known_entities=list(known))
112
+ if direct:
113
+ typed += StubTyper().type_candidates(episode.raw, direct, known_entities=list(known))
114
+
115
+ # Pass 5 — resolve, contradiction-check, persist (ADD-only).
116
+ written: list[str] = []
117
+ for tf in typed:
118
+ fact = self._build_fact(tf, namespace=namespace, episode_id=episode.id, store=store)
119
+
120
+ # Contradiction → supersession over the (subject, predicate) slot.
121
+ slot_latest = store.find_latest_in_slot(fact.subject_id, fact.predicate)
122
+ decision = self.contradiction.classify(
123
+ fact, slot_latest, self.embedder,
124
+ # ambiguous cases can escalate to the same typer; offline stub is fine.
125
+ llm_typer=None,
126
+ )
127
+
128
+ full, coarse = self.embedder.embed_with_coarse(fact.fact_text)
129
+ store.add_fact(fact, full, coarse)
130
+ # SUPERSEDES retires the matched fact; EXTENDS keeps both co-valid; ASSERTS
131
+ # touches nothing else. Insert-new-first so the FK target exists.
132
+ if decision.label == SUPERSEDES and decision.target is not None:
133
+ store.supersede_fact(decision.target.id, fact.id, valid_to=fact.valid_at)
134
+ written.append(fact.id)
135
+ return written
136
+
137
+ def _build_fact(
138
+ self, tf: TypedFact, *, namespace: str, episode_id: str, store: SqliteStore
139
+ ) -> Fact:
140
+ """Bind a TypedFact → a persistable Fact: resolve the subject entity, rate
141
+ salience once (cached), carry the relation's is_inference flag."""
142
+ subject = store.upsert_entity(Entity(namespace=namespace, name=tf.subject_name, type=None))
143
+ salience = score_salience(
144
+ tf.fact_text, source="extract", is_inference=bool(tf.is_inference)
145
+ )
146
+ ts = now_ms()
147
+ return Fact(
148
+ id=new_id(),
149
+ namespace=namespace,
150
+ subject_id=subject.id,
151
+ predicate=tf.predicate or _DEFAULT_PREDICATE,
152
+ object_literal=tf.object_literal,
153
+ fact_text=tf.fact_text,
154
+ valid_at=tf.valid_at,
155
+ episode_id=episode_id,
156
+ confidence=tf.confidence,
157
+ salience=salience,
158
+ is_inference=int(tf.is_inference),
159
+ ingested_at=ts,
160
+ created_at=ts,
161
+ )
162
+
163
+ def _known_entity_names(self, store: SqliteStore, namespace: str) -> set[str]:
164
+ """Names of entities already seen in this namespace — passed to the router/typer
165
+ as context only; they no longer drive escalation (the prior_entity trigger was
166
+ retired 2026-07 — see bench/results/calibration/README.md).
167
+ Capped to the most recent _KNOWN_ENTITIES_CAP names (ids are time-sortable)."""
168
+ rows = store._db.execute(
169
+ "SELECT name FROM entity WHERE namespace=? ORDER BY created_at DESC, id DESC LIMIT ?",
170
+ (namespace, _KNOWN_ENTITIES_CAP),
171
+ ).fetchall()
172
+ return {r["name"] for r in rows}
173
+
174
+ # ── retrieve ──
175
+ def search(
176
+ self,
177
+ namespace: str,
178
+ query: str,
179
+ k: int = 5,
180
+ *,
181
+ as_of: Optional[int] = None,
182
+ is_latest_only: bool = True,
183
+ now: Optional[int] = None,
184
+ ) -> list[RetrievedFact]:
185
+ """`now` (epoch ms) anchors the recency-decay term — pass the corpus's
186
+ present when querying historical data, else the wall clock is used and
187
+ recency is ≈0 for everything old (the term de-ranks nothing)."""
188
+ store = self._store(namespace)
189
+ retriever = Retriever(store, self.embedder, self.reranker)
190
+ return retriever.retrieve(
191
+ query, k, as_of=as_of, is_latest_only=is_latest_only, now=now
192
+ )
193
+
194
+ def close(self) -> None:
195
+ for s in self._stores.values():
196
+ s.close()
197
+ self._stores.clear()
198
+
199
+ def __enter__(self) -> "Memory":
200
+ return self
201
+
202
+ def __exit__(self, *exc) -> None:
203
+ self.close()
File without changes
@@ -0,0 +1,59 @@
1
+ """Reranker interface + default backends.
2
+
3
+ Per BET 1, the reranker is the single largest accuracy lever and is MANDATORY in the
4
+ default pipeline. But the production model is harness-decided (the sub-150M field —
5
+ Ettin-32M / mxbai-base / Qwen3-Reranker-0.6B — is within noise; do NOT assume
6
+ superiority). Phase 0 ships:
7
+ - IdentityReranker: passes fused order through (keeps tests offline/deterministic).
8
+ - CrossEncoderReranker: lazy sentence-transformers CrossEncoder, default Ettin-32M.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+
15
+
16
+ class Reranker(ABC):
17
+ @abstractmethod
18
+ def score(self, query: str, docs: list[str]) -> list[float]:
19
+ """Return one relevance score per doc (higher = better)."""
20
+
21
+
22
+ class IdentityReranker(Reranker):
23
+ """No-op reranker: preserves incoming order via a descending score ramp.
24
+
25
+ Used as the offline test default so the pipeline runs with zero model deps.
26
+ NOTE: this is explicitly NOT spec-compliant for production — the spec requires a
27
+ real cross-encoder. It exists only to make Phase 0 runnable without downloads.
28
+ """
29
+
30
+ def score(self, query: str, docs: list[str]) -> list[float]:
31
+ n = len(docs)
32
+ return [float(n - i) for i in range(n)]
33
+
34
+
35
+ class CrossEncoderReranker(Reranker):
36
+ """Lazy cross-encoder. Default = Ettin-32M (a verified, real sub-150M option)."""
37
+
38
+ def __init__(self, model_name: str = "cross-encoder/ettin-reranker-32m-v1") -> None:
39
+ self.model_name = model_name
40
+ self._model = None
41
+
42
+ def _ensure(self):
43
+ if self._model is None:
44
+ try:
45
+ from sentence_transformers import CrossEncoder
46
+ except ImportError as e: # pragma: no cover
47
+ raise ImportError(
48
+ "CrossEncoderReranker needs the 'models' extra: "
49
+ "pip install 'lean-memory[models]'"
50
+ ) from e
51
+ self._model = CrossEncoder(self.model_name)
52
+ return self._model
53
+
54
+ def score(self, query: str, docs: list[str]) -> list[float]:
55
+ if not docs:
56
+ return []
57
+ model = self._ensure()
58
+ pairs = [[query, d] for d in docs]
59
+ return [float(s) for s in model.predict(pairs)]