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,444 @@
1
+ """Pass 4 — LLM constrained typing / validation (the *residual*).
2
+
3
+ This is the last stage of the hybrid extraction pipeline (spec §5). Passes 1–3
4
+ already ran: rules + GLiNER2 over-generate high-recall ``(subject, relation,
5
+ object)`` candidates, and the recall-biased router (Pass 3) flagged the hard ones
6
+ (low GLiNER2 confidence, coreference/ellipsis, cross-turn references, possible
7
+ ``derives``). Everything the router did NOT escalate is trivially explicit and is
8
+ typed cheaply/deterministically. This module types the escalated residual.
9
+
10
+ The LLM here does **constrained classification, not open generation** (BET 2): for
11
+ each candidate it (a) assigns exactly one relation from the closed taxonomy
12
+ [asserts | supersedes | extends | derives], (b) sets ``is_inference`` (only
13
+ ``derives`` is inferential), (c) resolves coreference to a known entity, (d) may
14
+ surface an implicit cross-utterance edge. Open-ended generation is supermemory's
15
+ expensive, nondeterministic path — we deliberately avoid it.
16
+
17
+ WHY two backends (mirrors FakeEmbedder/IdentityReranker in Phase 0): the whole
18
+ spec is gated on "everything testable offline, zero downloads, zero servers."
19
+ ``OllamaTyper`` is the real local backend (a small Phi/Qwen-class model behind the
20
+ ``ollama`` HTTP client); ``StubTyper`` is a deterministic, dependency-free default
21
+ so the test suite — and the BET 2 ablation harness — runs with no Ollama server
22
+ and no model pull. ``Memory`` defaults to ``StubTyper`` exactly like it defaults to
23
+ ``FakeEmbedder``.
24
+
25
+ Note on the input contract: a ``Candidate`` here is the router's output (Pass 3),
26
+ not a persisted ``Fact``. We keep it defined locally (rather than importing from a
27
+ sibling Pass-2/3 module that may still be in flux) so this file is import-clean on
28
+ its own; the only cross-module contract is the relation taxonomy, imported from
29
+ ``taxonomy.py`` — the single source of truth for relation names and which relation
30
+ is inferential.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import re
37
+ from abc import ABC, abstractmethod
38
+ from dataclasses import dataclass
39
+ from typing import Optional
40
+
41
+ # ── Relation taxonomy + Candidate: the ONE cross-module contract. ──
42
+ # taxonomy.py is the single source of truth for both the relation set and the
43
+ # pre-typing `Candidate` shape. Pass 2 (gliner_extractor) emits canonical Candidates
44
+ # and Pass 3 (router) routes them, so Pass 4 MUST consume the same type — importing
45
+ # it here (rather than forking a local copy) is what keeps the Pass-3→Pass-4 handoff
46
+ # from crashing on field-name drift.
47
+ from .taxonomy import ( # noqa: E402
48
+ INFERENCE_CUES,
49
+ RELATIONS,
50
+ Candidate,
51
+ is_inference_relation,
52
+ )
53
+
54
+ # Relations the TYPER may emit. The typer decides ONLY the asserts-vs-derives split:
55
+ # it sees a single candidate utterance + episode context, NOT the prior-slot state, so
56
+ # it CANNOT correctly judge supersedes/extends (those need the existing slot fact and
57
+ # are the ContradictionResolver's job). Offering all four to a small model made it
58
+ # anchor on 'extends' for everything (0/10 on the BET-2 set). Constrain to {asserts,
59
+ # derives} to match StubTyper's decision surface exactly.
60
+ _TYPER_RELATIONS = ["asserts", "derives"]
61
+ # Full taxonomy kept for validation/reference.
62
+ _ALLOWED_RELATIONS = list(RELATIONS)
63
+
64
+
65
+ @dataclass
66
+ class TypedFact:
67
+ """The typer's output: a candidate bound to a final taxonomy relation + flags.
68
+
69
+ This is the hand-off to ``Memory.add`` / supersession (Pass 5). It carries
70
+ everything needed to build a ``Fact`` plus the relation decision that drives
71
+ versioning (``supersedes`` vs ``extends``) and the ``is_inference`` flag.
72
+ """
73
+
74
+ subject_name: str
75
+ predicate: str # the (possibly re-typed) slot predicate
76
+ relation: str # taxonomy: asserts | supersedes | extends | derives
77
+ fact_text: str
78
+ valid_at: int
79
+ is_inference: int # 1 iff relation == "derives"
80
+ confidence: float
81
+
82
+ object_literal: Optional[str] = None
83
+ subject_id: Optional[str] = None
84
+ object_id: Optional[str] = None
85
+ # Provenance of the decision: "stub" | "ollama" | "fallback" — lets the ablation
86
+ # harness attribute Relation-F1 to the backend that produced each label.
87
+ typed_by: str = "stub"
88
+
89
+ def __post_init__(self) -> None:
90
+ # Invariant the whole downstream pipeline relies on: relation is in-taxonomy
91
+ # and is_inference is consistent with it. Fail fast — a bad relation here
92
+ # corrupts supersession logic silently.
93
+ if self.relation not in RELATIONS:
94
+ raise ValueError(
95
+ f"relation {self.relation!r} not in taxonomy {RELATIONS!r}"
96
+ )
97
+ expected = 1 if is_inference_relation(self.relation) else 0
98
+ # We don't overwrite silently; mismatches are programmer errors.
99
+ if self.is_inference not in (0, 1):
100
+ raise ValueError(f"is_inference must be 0/1, got {self.is_inference!r}")
101
+ if self.is_inference != expected:
102
+ raise ValueError(
103
+ f"is_inference={self.is_inference} contradicts relation "
104
+ f"{self.relation!r} (expected {expected})"
105
+ )
106
+
107
+
108
+ class TyperError(RuntimeError):
109
+ """Raised when a real typing backend is unreachable (e.g. no Ollama server).
110
+
111
+ Callers catch this to fall back to ``StubTyper`` and keep the offline promise.
112
+ Kept distinct from ``ValueError`` so a *config* error (bad relation) is never
113
+ confused with an *availability* error (server down).
114
+ """
115
+
116
+
117
+ class Typer(ABC):
118
+ """Maps router-escalated candidates → typed facts. Pluggable, like Embedder.
119
+
120
+ Implementations MUST be pure functions of their inputs given a fixed backend
121
+ (deterministic decode / temperature 0) so the common path stays reproducible.
122
+ """
123
+
124
+ @abstractmethod
125
+ def type_candidates(
126
+ self,
127
+ episode_text: str,
128
+ candidates: list[Candidate],
129
+ known_entities: Optional[list[str]] = None,
130
+ ) -> list[TypedFact]:
131
+ """Type each candidate against the closed taxonomy.
132
+
133
+ Args:
134
+ episode_text: the full episode/turn text — context for coreference and
135
+ cross-utterance edges (the candidate's own ``fact_text`` may be a
136
+ fragment).
137
+ candidates: the residual to type (already filtered by Pass 3).
138
+ known_entities: canonical entity names visible to this namespace/session
139
+ — the resolution target for coreference. May be ``None``.
140
+
141
+ Returns:
142
+ One ``TypedFact`` per input candidate, in the same order.
143
+ """
144
+
145
+
146
+ # ── helpers shared by both backends ──
147
+ def _norm_relation(value: object) -> str:
148
+ """Coerce any backend's label to a valid taxonomy relation; default ``asserts``.
149
+
150
+ Defensive: an LLM (or a future stub) might emit casing/whitespace noise or a
151
+ synonym. Anything not exactly in the taxonomy collapses to ``asserts`` — the
152
+ safe, non-destructive default (it never triggers supersession).
153
+ """
154
+ if isinstance(value, str):
155
+ v = value.strip().lower()
156
+ if v in RELATIONS:
157
+ return v
158
+ return "asserts"
159
+
160
+
161
+ _CUE_RE = re.compile(r"\b(?:" + "|".join(re.escape(c) for c in INFERENCE_CUES) + r")\b", re.I)
162
+
163
+
164
+ def _looks_inferential(text: str) -> bool:
165
+ """Cheap, deterministic inference-cue check used by the stub backend.
166
+
167
+ Word-boundary matched (NOT bare substring) so 'so' inside 'also', 'since' inside
168
+ 'business', etc. do not false-fire and mistype an ordinary fact as `derives`.
169
+ """
170
+ return bool(_CUE_RE.search(text))
171
+
172
+
173
+ class StubTyper(Typer):
174
+ """Deterministic, dependency-free offline default — the ``FakeEmbedder`` analog.
175
+
176
+ No model, no server, no network. Rules (mirroring the spec's taxonomy semantics
177
+ but with zero learning):
178
+
179
+ * an explicit inference cue in the surface text → ``derives`` (is_inference=1)
180
+ * everything else → ``asserts`` (is_inference=0)
181
+
182
+ It performs **no coreference resolution beyond identity**: a candidate's
183
+ ``subject_name`` is matched case-insensitively against ``known_entities`` and,
184
+ on an exact hit, ``subject_id`` is left as-is (already resolved) — it never
185
+ invents a link. ``supersedes``/``extends`` are intentionally NOT decided here:
186
+ those are a *slot-level* judgment (does the new object contradict the latest
187
+ object in the same subject+predicate slot?) made by the cheap-then-escalate
188
+ contradiction check in ``Memory.add`` (Pass 5), not by per-candidate typing.
189
+ Emitting them blindly here would corrupt versioning, so the stub stays at the
190
+ honest ``asserts``/``derives`` split.
191
+ """
192
+
193
+ def type_candidates(
194
+ self,
195
+ episode_text: str,
196
+ candidates: list[Candidate],
197
+ known_entities: Optional[list[str]] = None,
198
+ ) -> list[TypedFact]:
199
+ out: list[TypedFact] = []
200
+ for c in candidates:
201
+ # Inference cue is checked on the candidate sentence (word-boundary, via
202
+ # _looks_inferential) — not bare substring, which false-fired on 'also'.
203
+ inferential = _looks_inferential(c.fact_text)
204
+ relation = "derives" if inferential else "asserts"
205
+ out.append(
206
+ TypedFact(
207
+ subject_name=c.subject_name,
208
+ predicate=c.predicate or "",
209
+ relation=relation,
210
+ fact_text=c.fact_text,
211
+ valid_at=c.valid_at,
212
+ is_inference=1 if relation == "derives" else 0,
213
+ # Typing adds no information beyond rules → keep the candidate's
214
+ # confidence (don't inflate it the way a real LLM pass would).
215
+ confidence=c.confidence,
216
+ object_literal=c.object_literal,
217
+ # Entity ids are resolved by Memory (upsert_entity), not the typer.
218
+ subject_id=None,
219
+ object_id=c.object_id,
220
+ typed_by="stub",
221
+ )
222
+ )
223
+ return out
224
+
225
+
226
+ # JSON-Schema (hand-built, no pydantic at import time) constraining the LLM to a
227
+ # typed array, one record per candidate, ``relation`` an enum over the taxonomy.
228
+ # This is what makes the call "constrained classification, not generation": Ollama's
229
+ # structured-output grammar forces the model to pick a relation, not write prose.
230
+ def _response_schema(n: int) -> dict:
231
+ item = {
232
+ "type": "object",
233
+ "properties": {
234
+ "index": {"type": "integer"}, # ties the decision back to candidates[i]
235
+ "relation": {"type": "string", "enum": _TYPER_RELATIONS},
236
+ "is_inference": {"type": "integer", "enum": [0, 1]},
237
+ "subject": {"type": "string"}, # resolved/canonical subject name
238
+ },
239
+ "required": ["index", "relation", "is_inference", "subject"],
240
+ }
241
+ return {
242
+ "type": "object",
243
+ "properties": {
244
+ "results": {
245
+ "type": "array",
246
+ "items": item,
247
+ "minItems": n,
248
+ "maxItems": n,
249
+ }
250
+ },
251
+ "required": ["results"],
252
+ }
253
+
254
+
255
+ _SYSTEM_PROMPT = (
256
+ "You are a relation TYPING module inside a memory engine. You do NOT generate "
257
+ "free text and you do NOT invent facts. For each numbered candidate, decide "
258
+ "whether the fact is directly STATED or is an INFERENCE drawn from the episode "
259
+ "context, and output exactly one relation:\n"
260
+ " - asserts: the fact is stated directly/explicitly in the candidate text.\n"
261
+ " - derives: the fact is NOT stated verbatim — it is inferred as a likely "
262
+ "consequence of other information in the episode (set is_inference=1).\n"
263
+ "Examples: episode 'I live 40km away and own no car. I take the train.' → the "
264
+ "train fact is stated, BUT if the candidate were 'I commute somehow', deriving "
265
+ "the train is inference. A fact that simply restates what the episode says is "
266
+ "'asserts'; a fact you can only conclude by reasoning over the episode is "
267
+ "'derives'.\n"
268
+ "Set is_inference=1 only for 'derives', otherwise 0. Resolve the subject to one "
269
+ "of the known entities when the candidate uses a pronoun or ellipsis; otherwise "
270
+ "keep the given subject. Return one result object per candidate, in order, with "
271
+ "the candidate's index. Output ONLY the JSON object required by the schema."
272
+ )
273
+
274
+
275
+ class OllamaTyper(Typer):
276
+ """Real backend: constrained typing via a small local model behind Ollama.
277
+
278
+ ``ollama`` is imported lazily (it is an optional dep, like sentence-transformers)
279
+ so this module stays import-clean without it. The model is constrained with
280
+ Ollama's ``format=<json schema>`` structured-output mode and ``temperature=0``
281
+ for a reproducible decode. On an unreachable server (no Ollama running — the
282
+ common CI/offline case) we raise :class:`TyperError`, which the facade catches to
283
+ fall back to :class:`StubTyper`.
284
+ """
285
+
286
+ #: A small, locally-pullable default — Phi/Qwen-class per spec ("local Phi/Qwen").
287
+ DEFAULT_MODEL = "qwen2.5:3b"
288
+
289
+ def __init__(
290
+ self,
291
+ model: str = DEFAULT_MODEL,
292
+ *,
293
+ host: Optional[str] = None,
294
+ temperature: float = 0.0,
295
+ ) -> None:
296
+ self.model = model
297
+ self.host = host
298
+ self.temperature = temperature
299
+ self._client = None # lazily constructed on first call
300
+
301
+ def _get_client(self):
302
+ """Lazily import ``ollama`` and build a client. Raises TyperError if the
303
+ package is absent (treated as 'backend unavailable', not a crash)."""
304
+ if self._client is not None:
305
+ return self._client
306
+ try:
307
+ import ollama # type: ignore
308
+ except ImportError as e: # optional dep not installed → unavailable backend
309
+ raise TyperError(
310
+ "ollama package not installed; install with the 'llm' extra "
311
+ "(pip install lean-memory[llm]) or use StubTyper for offline."
312
+ ) from e
313
+ # A Client lets us honor a custom host; module-level fns hit localhost.
314
+ self._client = ollama.Client(host=self.host) if self.host else ollama
315
+ return self._client
316
+
317
+ def type_candidates(
318
+ self,
319
+ episode_text: str,
320
+ candidates: list[Candidate],
321
+ known_entities: Optional[list[str]] = None,
322
+ ) -> list[TypedFact]:
323
+ if not candidates:
324
+ return []
325
+ client = self._get_client()
326
+ user_prompt = self._build_user_prompt(episode_text, candidates, known_entities)
327
+ schema = _response_schema(len(candidates))
328
+
329
+ try:
330
+ resp = client.chat(
331
+ model=self.model,
332
+ messages=[
333
+ {"role": "system", "content": _SYSTEM_PROMPT},
334
+ {"role": "user", "content": user_prompt},
335
+ ],
336
+ format=schema, # JSON-Schema → constrained, classification-only output
337
+ options={"temperature": self.temperature},
338
+ )
339
+ except ConnectionError as e:
340
+ # Builtin ConnectionError == "no Ollama server" (ollama 0.6.x re-raises
341
+ # httpx ConnectError as this). The exact catchable signal for fallback.
342
+ raise TyperError(
343
+ f"cannot reach Ollama (is the server running at "
344
+ f"{self.host or 'localhost:11434'}?): {e}"
345
+ ) from e
346
+ except Exception as e: # ResponseError (e.g. model not pulled, 404) etc.
347
+ raise TyperError(f"Ollama typing call failed: {e}") from e
348
+
349
+ content = self._extract_content(resp)
350
+ decisions = self._parse_decisions(content, len(candidates))
351
+ return self._apply_decisions(candidates, decisions, known_entities)
352
+
353
+ # ── prompt / response plumbing (kept small + deterministic) ──
354
+ @staticmethod
355
+ def _build_user_prompt(
356
+ episode_text: str,
357
+ candidates: list[Candidate],
358
+ known_entities: Optional[list[str]],
359
+ ) -> str:
360
+ lines = [f"EPISODE:\n{episode_text.strip()}", ""]
361
+ if known_entities:
362
+ lines.append("KNOWN ENTITIES: " + ", ".join(known_entities))
363
+ lines.append("")
364
+ lines.append("CANDIDATES:")
365
+ for i, c in enumerate(candidates):
366
+ obj = f" -> {c.object_literal}" if c.object_literal else ""
367
+ lines.append(
368
+ f"[{i}] subject={c.subject_name!r} predicate={c.predicate!r}{obj} "
369
+ f"| text: {c.fact_text.strip()}"
370
+ )
371
+ return "\n".join(lines)
372
+
373
+ @staticmethod
374
+ def _extract_content(resp: object) -> str:
375
+ """Pull the JSON string from a ChatResponse, tolerating attr/dict access.
376
+
377
+ Real ollama ``ChatResponse`` exposes ``.message.content``; we also accept the
378
+ dict form so a hand-rolled stub response (tests) works unchanged.
379
+ """
380
+ msg = getattr(resp, "message", None)
381
+ if msg is not None:
382
+ content = getattr(msg, "content", None)
383
+ if content is not None:
384
+ return content
385
+ try:
386
+ return resp["message"]["content"] # type: ignore[index]
387
+ except (TypeError, KeyError):
388
+ raise TyperError(f"unexpected Ollama response shape: {resp!r}")
389
+
390
+ @staticmethod
391
+ def _parse_decisions(content: str, n: int) -> list[dict]:
392
+ """Parse the constrained JSON. Schema guarantees shape, but we stay defensive
393
+ so a single malformed decode degrades to defaults rather than crashing."""
394
+ try:
395
+ data = json.loads(content)
396
+ except (json.JSONDecodeError, TypeError) as e:
397
+ raise TyperError(f"Ollama returned non-JSON content: {e}") from e
398
+ results = data.get("results") if isinstance(data, dict) else None
399
+ if not isinstance(results, list):
400
+ raise TyperError(f"Ollama JSON missing 'results' array: {content!r}")
401
+ # Index decisions for order-independent application.
402
+ by_index: dict[int, dict] = {}
403
+ for r in results:
404
+ if isinstance(r, dict) and isinstance(r.get("index"), int):
405
+ by_index[r["index"]] = r
406
+ return [by_index.get(i, {}) for i in range(n)]
407
+
408
+ @staticmethod
409
+ def _apply_decisions(
410
+ candidates: list[Candidate],
411
+ decisions: list[dict],
412
+ known_entities: Optional[list[str]],
413
+ ) -> list[TypedFact]:
414
+ known_lookup = {e.lower(): e for e in (known_entities or [])}
415
+ out: list[TypedFact] = []
416
+ for c, d in zip(candidates, decisions):
417
+ relation = _norm_relation(d.get("relation"))
418
+ inferred = 1 if is_inference_relation(relation) else 0
419
+ # Coreference: prefer the model's resolved subject if it names a known
420
+ # entity; otherwise keep the candidate's own subject (no fabrication).
421
+ resolved = d.get("subject")
422
+ subject_name = c.subject_name
423
+ if isinstance(resolved, str) and resolved.strip():
424
+ key = resolved.strip().lower()
425
+ subject_name = known_lookup.get(key, resolved.strip())
426
+ out.append(
427
+ TypedFact(
428
+ subject_name=subject_name,
429
+ predicate=c.predicate or "",
430
+ relation=relation,
431
+ fact_text=c.fact_text,
432
+ valid_at=c.valid_at,
433
+ is_inference=inferred,
434
+ # A successful LLM typing pass is the spec's quality lever → lift
435
+ # confidence modestly above the raw GLiNER2/rules guess.
436
+ confidence=max(c.confidence, 0.75),
437
+ object_literal=c.object_literal,
438
+ # Entity ids are resolved by Memory (upsert_entity), not the typer.
439
+ subject_id=None,
440
+ object_id=c.object_id,
441
+ typed_by="ollama",
442
+ )
443
+ )
444
+ return out