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.
- lean_memory/__init__.py +14 -0
- lean_memory/embed/__init__.py +0 -0
- lean_memory/embed/base.py +51 -0
- lean_memory/embed/fake.py +41 -0
- lean_memory/embed/sentence_transformer.py +65 -0
- lean_memory/extract/__init__.py +0 -0
- lean_memory/extract/contradiction.py +329 -0
- lean_memory/extract/gliner_extractor.py +483 -0
- lean_memory/extract/llm_typer.py +444 -0
- lean_memory/extract/router.py +418 -0
- lean_memory/extract/rules.py +130 -0
- lean_memory/extract/salience.py +123 -0
- lean_memory/extract/taxonomy.py +244 -0
- lean_memory/mcp_server.py +150 -0
- lean_memory/memory.py +203 -0
- lean_memory/retrieve/__init__.py +0 -0
- lean_memory/retrieve/rerank.py +59 -0
- lean_memory/retrieve/retriever.py +125 -0
- lean_memory/store/__init__.py +0 -0
- lean_memory/store/base.py +91 -0
- lean_memory/store/schema.py +83 -0
- lean_memory/store/sqlite_store.py +301 -0
- lean_memory/types.py +107 -0
- lean_memory-0.1.0.dist-info/METADATA +228 -0
- lean_memory-0.1.0.dist-info/RECORD +27 -0
- lean_memory-0.1.0.dist-info/WHEEL +4 -0
- lean_memory-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Pass 3 — the recall-biased router (design-spec §5, Pass 3).
|
|
2
|
+
|
|
3
|
+
After Pass 1 (rules) and Pass 2 (GLiNER2) over-generate high-recall candidates, the
|
|
4
|
+
vast majority are *trivially explicit, high-confidence, intra-utterance* facts that a
|
|
5
|
+
local LLM would only rubber-stamp at a cost. The router's job is to spend the LLM
|
|
6
|
+
budget only where deterministic extraction is known to fail (BET 2, corrected 2026-06:
|
|
7
|
+
GLiNER2-class relation typing is weak — ~17.8% zero-shot Micro-F1 — and inferential /
|
|
8
|
+
cross-turn edges are exactly the residual the LLM must own).
|
|
9
|
+
|
|
10
|
+
A candidate is ESCALATED to the Pass-4 LLM-typing batch if ANY of:
|
|
11
|
+
1. GLiNER2 confidence below `conf_threshold` — the parser is unsure;
|
|
12
|
+
2. coreference / ellipsis / zero-pronoun detected — the span isn't self-contained;
|
|
13
|
+
3. it is a possible `derives` (inferential) edge — only the LLM may emit `is_inference=1`.
|
|
14
|
+
Everything else is routed `direct` (skips the LLM) and gets the cheap `asserts`/slot path.
|
|
15
|
+
|
|
16
|
+
RETIRED (Task 6, 2026-07): a former criterion escalated any candidate touching a
|
|
17
|
+
*previously-seen* entity (`known_entities`). It fired on 52.8% of real conversational
|
|
18
|
+
candidates (subject re-mention is normal discourse, not a hard cross-turn case) and was
|
|
19
|
+
the last confidence-independent floor over the <20% gate. Entity linking is deterministic
|
|
20
|
+
by name (`upsert_entity`); genuinely ambiguous references still escalate via coref, and
|
|
21
|
+
inferential edges via derives. `known_entities` is still accepted (the typer uses the
|
|
22
|
+
names as context) but no longer drives escalation.
|
|
23
|
+
|
|
24
|
+
WHY this is its own deterministic pass (no model): the router IS the cost story. The
|
|
25
|
+
spec gates the whole BET-2 design on escalation rate staying < 20%; if it trends to
|
|
26
|
+
100% the hybrid is no cheaper than 100%-LLM and we revisit. So the router must be pure
|
|
27
|
+
stdlib, fully reproducible, and — critically — must surface its escalation rate as a
|
|
28
|
+
first-class, inspectable metric (`last_stats`) that the BET-2 ablation harness reads.
|
|
29
|
+
|
|
30
|
+
Like FakeEmbedder / IdentityReranker in Phase 0, this is the always-offline default:
|
|
31
|
+
zero downloads, zero servers, deterministic. The real GLiNER2 (Pass 2) and Ollama LLM
|
|
32
|
+
(Pass 4) sit behind their own interfaces with their own stubs; the router never calls
|
|
33
|
+
either — it only *decides* who gets escalated, from the candidate metadata alone.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from typing import Iterable, Optional
|
|
41
|
+
|
|
42
|
+
# `Candidate` is the Pass-2 output contract, owned by the sibling taxonomy module.
|
|
43
|
+
# We import the symbol for type-hinting (the load-bearing module boundary), but read
|
|
44
|
+
# its fields through defensive accessors below so a parallel-built `Candidate` whose
|
|
45
|
+
# exact attribute names differ slightly still routes correctly rather than crashing.
|
|
46
|
+
from .taxonomy import Candidate
|
|
47
|
+
|
|
48
|
+
# ── escalation reason codes (stable strings → cheap to assert on / aggregate) ──
|
|
49
|
+
REASON_LOW_CONF = "low_confidence"
|
|
50
|
+
REASON_COREF = "coreference"
|
|
51
|
+
# DEPRECATED (Task 6, 2026-07): `prior_entity` was retired as an escalation trigger
|
|
52
|
+
# (subject re-mention fired on 52.8% of real candidates — normal discourse, not a hard
|
|
53
|
+
# case; entity linking is deterministic by name, ambiguous refs escalate via coref).
|
|
54
|
+
# The constant is kept because historical probe/telemetry JSONs reference the string;
|
|
55
|
+
# the router never emits it anymore. See _reasons() and the calibration README.
|
|
56
|
+
REASON_PRIOR_ENTITY = "prior_entity"
|
|
57
|
+
REASON_DERIVES = "derives"
|
|
58
|
+
REASON_PRE_FLAGGED = "pre_flagged" # Pass-2 `needs_typing` already requested typing
|
|
59
|
+
|
|
60
|
+
# The relation taxonomy. `derives` is LLM-only (is_inference=1); the deterministic
|
|
61
|
+
# passes may only ever propose the structural three. An unknown/empty predicate is
|
|
62
|
+
# itself a signal that typing is needed, so it escalates.
|
|
63
|
+
_STRUCTURAL_RELATIONS = frozenset({"asserts", "supersedes", "extends"})
|
|
64
|
+
_KNOWN_PREDICATES = frozenset(
|
|
65
|
+
{
|
|
66
|
+
# The full slot lexicon emitted by Pass 1 (rules) AND Pass 2 (gliner_extractor
|
|
67
|
+
# DEFAULT_RELATION_TYPES + _VERB_RELATIONS). Must stay a SUPERSET of both, else
|
|
68
|
+
# a confidently-typed predicate gets mis-escalated as "possible derives" and the
|
|
69
|
+
# escalation rate blows past the BET-2 <20% target on the offline default.
|
|
70
|
+
"works_at",
|
|
71
|
+
"lives_in",
|
|
72
|
+
"located_in",
|
|
73
|
+
"likes",
|
|
74
|
+
"dislikes",
|
|
75
|
+
"is_a",
|
|
76
|
+
"has",
|
|
77
|
+
"uses",
|
|
78
|
+
"knows",
|
|
79
|
+
# gliner_extractor DEFAULT_RELATION_TYPES extras not in the original list:
|
|
80
|
+
"owns",
|
|
81
|
+
"member_of",
|
|
82
|
+
# common predicates the typer and gold set exercise (commutes_by, speaks, etc.
|
|
83
|
+
# are *inferential* slots — they stay out; the ones below are explicit):
|
|
84
|
+
"drives",
|
|
85
|
+
"speaks",
|
|
86
|
+
"plays",
|
|
87
|
+
"skilled_in",
|
|
88
|
+
"interested_in",
|
|
89
|
+
"works_in",
|
|
90
|
+
"commutes_by",
|
|
91
|
+
}
|
|
92
|
+
) | _STRUCTURAL_RELATIONS
|
|
93
|
+
|
|
94
|
+
# ── coreference / ellipsis heuristics ──
|
|
95
|
+
# Endpoint-level pronouns/demonstratives: a candidate whose OWN subject or object
|
|
96
|
+
# is one of these is not self-contained. This replaces the old whole-text scan,
|
|
97
|
+
# which fired on conversational filler ("that", "it", "there") in 65.6% of real
|
|
98
|
+
# turns (2026-07 baseline probe) and put a hard floor over the <20% target.
|
|
99
|
+
_ENDPOINT_PRONOUNS = frozenset({
|
|
100
|
+
"he", "him", "his", "she", "her", "hers", "they", "them", "their", "theirs",
|
|
101
|
+
"it", "its", "this", "that", "these", "those",
|
|
102
|
+
"the former", "the latter", "the same",
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
# Inferential cue words → a candidate that *might* be a `derives` edge. The router only
|
|
106
|
+
# flags it for the LLM to confirm; the router itself NEVER assigns `derives`.
|
|
107
|
+
_INFERENCE_CUES = re.compile(
|
|
108
|
+
r"\b("
|
|
109
|
+
r"so|therefore|thus|hence|because|since|"
|
|
110
|
+
r"must|probably|likely|presumably|implies|imply|implied|"
|
|
111
|
+
r"means|suggests?|consequently|as\s+a\s+result"
|
|
112
|
+
r")\b",
|
|
113
|
+
re.I,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# A leading conjunction / verb with no overt subject ⇒ likely an elided (zero-pronoun)
|
|
117
|
+
# subject carried from the prior clause/turn ("...and moved to Berlin", "Then joined X").
|
|
118
|
+
_ELLIPSIS_LEAD = re.compile(r"^\s*(and|but|then|also|plus|so)\b", re.I)
|
|
119
|
+
_LEADING_VERB = re.compile(
|
|
120
|
+
r"^\s*(works?|lives?|moved|joined|left|likes?|loves?|hates?|uses?|has|had|is|are|was|were)\b",
|
|
121
|
+
re.I,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class RouteStats:
|
|
127
|
+
"""Escalation-rate metrics — the router's first-class, inspectable output.
|
|
128
|
+
|
|
129
|
+
`rate` is escalated/seen (0.0 when nothing was seen). The BET-2 ablation harness
|
|
130
|
+
reads this to assert the < 20% target and to log tokens-saved vs 100%-LLM.
|
|
131
|
+
`by_reason` breaks the escalations down so we can see *why* the budget is spent.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
seen: int = 0
|
|
135
|
+
escalated: int = 0
|
|
136
|
+
by_reason: dict[str, int] = field(default_factory=dict)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def rate(self) -> float:
|
|
140
|
+
return (self.escalated / self.seen) if self.seen else 0.0
|
|
141
|
+
|
|
142
|
+
def as_dict(self) -> dict:
|
|
143
|
+
# Matches the spec's required {seen, escalated, rate} shape, plus the breakdown.
|
|
144
|
+
return {
|
|
145
|
+
"seen": self.seen,
|
|
146
|
+
"escalated": self.escalated,
|
|
147
|
+
"rate": self.rate,
|
|
148
|
+
"by_reason": dict(self.by_reason),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── candidate-field accessors (defensive: tolerate sibling-module naming drift) ──
|
|
153
|
+
def _cand_confidence(c: Candidate) -> float:
|
|
154
|
+
"""GLiNER2 (or rules) confidence in [0,1]. Missing ⇒ 0.0 ⇒ escalate (recall-biased).
|
|
155
|
+
|
|
156
|
+
Reads `confidence` (taxonomy/gliner contract) and falls back to `gliner_confidence`
|
|
157
|
+
so a Candidate carrying only the typer-side confidence name still routes correctly.
|
|
158
|
+
"""
|
|
159
|
+
for attr in ("confidence", "gliner_confidence"):
|
|
160
|
+
val = getattr(c, attr, None)
|
|
161
|
+
if val is not None:
|
|
162
|
+
try:
|
|
163
|
+
return float(val)
|
|
164
|
+
except (TypeError, ValueError):
|
|
165
|
+
return 0.0
|
|
166
|
+
return 0.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _cand_pre_flagged(c: Candidate) -> bool:
|
|
170
|
+
"""The upstream `needs_typing` pre-flag (Pass 2 sets it for low-confidence spans).
|
|
171
|
+
|
|
172
|
+
The GLiNER2 generator already marks borderline candidates; the router ORs that flag
|
|
173
|
+
with its own coref/ellipsis/cross-turn/derives triggers so a pre-flagged span is
|
|
174
|
+
never accidentally routed `direct`. Absent ⇒ False (no pre-flag, decide on triggers).
|
|
175
|
+
"""
|
|
176
|
+
return bool(getattr(c, "needs_typing", False))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _cand_text(c: Candidate) -> str:
|
|
180
|
+
"""The standalone span text the coref/ellipsis/inference heuristics run over."""
|
|
181
|
+
for attr in ("fact_text", "text", "sentence"):
|
|
182
|
+
val = getattr(c, attr, None)
|
|
183
|
+
if isinstance(val, str) and val:
|
|
184
|
+
return val
|
|
185
|
+
return ""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _cand_predicate(c: Candidate) -> str:
|
|
189
|
+
val = getattr(c, "predicate", None)
|
|
190
|
+
return val if isinstance(val, str) else ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _cand_subject_name(c: Candidate) -> Optional[str]:
|
|
194
|
+
for attr in ("subject_name", "subject", "head", "head_text"):
|
|
195
|
+
val = getattr(c, attr, None)
|
|
196
|
+
if isinstance(val, str) and val:
|
|
197
|
+
return val
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _cand_object_name(c: Candidate) -> Optional[str]:
|
|
202
|
+
for attr in ("object_name", "object_literal", "object_text", "object", "tail", "tail_text"):
|
|
203
|
+
val = getattr(c, attr, None)
|
|
204
|
+
if isinstance(val, str) and val:
|
|
205
|
+
return val
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _cand_introduced_here(c: Candidate) -> Optional[set[str]]:
|
|
210
|
+
"""The entity names Pass 2 says were FIRST introduced in *this* episode, if it told us.
|
|
211
|
+
|
|
212
|
+
When the candidate carries this set we trust it; otherwise we return None and the
|
|
213
|
+
caller falls back to plain `known_entities` membership (still recall-biased). This is
|
|
214
|
+
optional metadata — the real Pass-2 `Candidate` may not populate it, which is fine."""
|
|
215
|
+
val = getattr(c, "introduced_here", None)
|
|
216
|
+
if isinstance(val, (set, frozenset)):
|
|
217
|
+
return {str(x) for x in val}
|
|
218
|
+
if isinstance(val, (list, tuple)):
|
|
219
|
+
return {str(x) for x in val}
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _norm(name: Optional[str]) -> str:
|
|
224
|
+
"""Case/space-insensitive entity-name key so 'Tim Cook' == 'tim cook'."""
|
|
225
|
+
return re.sub(r"\s+", " ", name.strip().lower()) if name else ""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _record_reasons(c: Candidate, reasons: list[str]) -> None:
|
|
229
|
+
"""Best-effort: stamp WHY a candidate escalated onto its `escalation_reasons` field.
|
|
230
|
+
|
|
231
|
+
Both the taxonomy `Candidate` and the typer's `Candidate` declare this field so
|
|
232
|
+
Pass 4 / the ablation harness can attribute each escalation. Purely informational —
|
|
233
|
+
if the candidate is frozen/slotted and won't accept the assignment we silently skip
|
|
234
|
+
it (the authoritative record is always the router's own `by_reason` stats)."""
|
|
235
|
+
if not hasattr(c, "escalation_reasons"):
|
|
236
|
+
return
|
|
237
|
+
try:
|
|
238
|
+
c.escalation_reasons = tuple(reasons) # type: ignore[attr-defined]
|
|
239
|
+
except (AttributeError, TypeError):
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class RecallBiasedRouter:
|
|
244
|
+
"""Decide which Pass-2 candidates need Pass-4 LLM typing — and audit how often.
|
|
245
|
+
|
|
246
|
+
Deterministic and model-free. `route()` partitions candidates into
|
|
247
|
+
(to_type, direct) and updates the running escalation metrics; `last_stats`
|
|
248
|
+
exposes the most recent call's {seen, escalated, rate, by_reason}, and
|
|
249
|
+
`cumulative_stats` aggregates across every call on this instance.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __init__(self, conf_threshold: float = 0.4) -> None:
|
|
253
|
+
# The recall knob. Anything the parser was less-than-`conf_threshold` sure of is
|
|
254
|
+
# sent to the LLM. Frozen 2026-07 to 0.4 (was 0.5) — the chosen escalation operating
|
|
255
|
+
# point from the real-turn calibration after the coref (Task 4) + prior_entity-drop
|
|
256
|
+
# (Task 6) fixes: at (typing=0.4, conf=0.4) the post-drop probe escalates 14.6%
|
|
257
|
+
# (103/704, derives-dominated), the highest (typing, conf) pair with probe rate < 0.15.
|
|
258
|
+
# See bench/results/calibration/2026-07-escalation-postdrop-p1.json + the calibration
|
|
259
|
+
# README. Tunable so the ablation harness can still sweep it.
|
|
260
|
+
self.conf_threshold = float(conf_threshold)
|
|
261
|
+
self._last = RouteStats()
|
|
262
|
+
self._cum = RouteStats()
|
|
263
|
+
|
|
264
|
+
# ── public API ──
|
|
265
|
+
def route(
|
|
266
|
+
self,
|
|
267
|
+
candidates: Iterable[Candidate],
|
|
268
|
+
known_entities: Optional[Iterable[str]] = None,
|
|
269
|
+
self_entity: Optional[str] = "user",
|
|
270
|
+
) -> tuple[list[Candidate], list[Candidate]]:
|
|
271
|
+
"""Partition `candidates` into (to_type, direct).
|
|
272
|
+
|
|
273
|
+
`to_type` → escalated to the Pass-4 LLM-typing batch (hard spans).
|
|
274
|
+
`direct` → skip the LLM; cheap deterministic `asserts`/slot path.
|
|
275
|
+
|
|
276
|
+
`known_entities` is the set of entity names already seen in PRIOR turns/sessions
|
|
277
|
+
of this namespace. As of Task 6 (2026-07) it NO LONGER drives escalation — the
|
|
278
|
+
`prior_entity` trigger was retired (it fired on 52.8% of real candidates without
|
|
279
|
+
recall benefit; entity linking is deterministic by name). The parameter is kept
|
|
280
|
+
for API stability and because the Pass-4 typer uses these names as context.
|
|
281
|
+
|
|
282
|
+
`self_entity` is the namespace-owner / first-person persona name (default "user").
|
|
283
|
+
It was the exemption for the retired `prior_entity` trigger and is likewise no
|
|
284
|
+
longer consulted for escalation; kept for API stability.
|
|
285
|
+
"""
|
|
286
|
+
known = {_norm(e) for e in known_entities} if known_entities else set()
|
|
287
|
+
self_key = _norm(self_entity) if self_entity else ""
|
|
288
|
+
|
|
289
|
+
to_type: list[Candidate] = []
|
|
290
|
+
direct: list[Candidate] = []
|
|
291
|
+
stats = RouteStats()
|
|
292
|
+
|
|
293
|
+
for cand in candidates:
|
|
294
|
+
stats.seen += 1
|
|
295
|
+
reasons = self._reasons(cand, known, self_key)
|
|
296
|
+
if reasons:
|
|
297
|
+
stats.escalated += 1
|
|
298
|
+
for r in reasons:
|
|
299
|
+
stats.by_reason[r] = stats.by_reason.get(r, 0) + 1
|
|
300
|
+
_record_reasons(cand, reasons)
|
|
301
|
+
to_type.append(cand)
|
|
302
|
+
else:
|
|
303
|
+
direct.append(cand)
|
|
304
|
+
|
|
305
|
+
self._last = stats
|
|
306
|
+
self._accumulate(stats)
|
|
307
|
+
return to_type, direct
|
|
308
|
+
|
|
309
|
+
def should_escalate(
|
|
310
|
+
self,
|
|
311
|
+
candidate: Candidate,
|
|
312
|
+
known_entities: Optional[Iterable[str]] = None,
|
|
313
|
+
self_entity: Optional[str] = "user",
|
|
314
|
+
) -> bool:
|
|
315
|
+
"""Single-candidate convenience (does NOT touch the running metrics)."""
|
|
316
|
+
known = {_norm(e) for e in known_entities} if known_entities else set()
|
|
317
|
+
self_key = _norm(self_entity) if self_entity else ""
|
|
318
|
+
return bool(self._reasons(candidate, known, self_key))
|
|
319
|
+
|
|
320
|
+
# ── metrics surface (first-class, per spec) ──
|
|
321
|
+
@property
|
|
322
|
+
def last_stats(self) -> dict:
|
|
323
|
+
"""{seen, escalated, rate, by_reason} for the most recent route() call."""
|
|
324
|
+
return self._last.as_dict()
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def cumulative_stats(self) -> dict:
|
|
328
|
+
"""Aggregate {seen, escalated, rate, by_reason} across all route() calls."""
|
|
329
|
+
return self._cum.as_dict()
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def escalation_rate(self) -> float:
|
|
333
|
+
"""Cumulative escalated/seen — the < 20% BET-2 target metric."""
|
|
334
|
+
return self._cum.rate
|
|
335
|
+
|
|
336
|
+
def reset_stats(self) -> None:
|
|
337
|
+
self._last = RouteStats()
|
|
338
|
+
self._cum = RouteStats()
|
|
339
|
+
|
|
340
|
+
# ── escalation logic ──
|
|
341
|
+
def _reasons(self, cand: Candidate, known: set[str], self_key: str = "") -> list[str]:
|
|
342
|
+
"""Collect every reason this candidate must be escalated (order = check order).
|
|
343
|
+
|
|
344
|
+
Returns [] ⇒ route direct. Multiple reasons are kept so `by_reason` reflects the
|
|
345
|
+
true distribution of *why* spans escalate (a span can be both low-conf and coref)."""
|
|
346
|
+
reasons: list[str] = []
|
|
347
|
+
text = _cand_text(cand)
|
|
348
|
+
|
|
349
|
+
# 0. Upstream pre-flag: Pass 2 already decided this span needs typing. OR it in
|
|
350
|
+
# so a pre-flagged candidate is never routed direct (gliner_extractor relies
|
|
351
|
+
# on the router honoring `needs_typing`).
|
|
352
|
+
if _cand_pre_flagged(cand):
|
|
353
|
+
reasons.append(REASON_PRE_FLAGGED)
|
|
354
|
+
|
|
355
|
+
# 1. Low parser confidence — recall-biased: unsure ⇒ escalate.
|
|
356
|
+
if _cand_confidence(cand) < self.conf_threshold:
|
|
357
|
+
reasons.append(REASON_LOW_CONF)
|
|
358
|
+
|
|
359
|
+
# 2. Coreference / ellipsis / zero-pronoun: the candidate itself is not
|
|
360
|
+
# self-contained (endpoint-scoped — see _coref_or_ellipsis).
|
|
361
|
+
if self._coref_or_ellipsis(cand, text, self_key):
|
|
362
|
+
reasons.append(REASON_COREF)
|
|
363
|
+
|
|
364
|
+
# 3. Possible `derives` (inferential) edge — LLM-only relation.
|
|
365
|
+
if self._is_possible_derives(cand, text):
|
|
366
|
+
reasons.append(REASON_DERIVES)
|
|
367
|
+
|
|
368
|
+
# NOTE (Task 6, 2026-07): a former criterion here escalated any candidate
|
|
369
|
+
# touching a `known_entities` member. Retired — it fired on 52.8% of real
|
|
370
|
+
# candidates (subject re-mention is normal discourse) and blocked the <20%
|
|
371
|
+
# gate. `known` / `self_key` are still threaded through for API stability but
|
|
372
|
+
# no longer consulted. See REASON_PRIOR_ENTITY (deprecated) and the docstring.
|
|
373
|
+
|
|
374
|
+
return reasons
|
|
375
|
+
|
|
376
|
+
def _coref_or_ellipsis(self, cand: Candidate, text: str, self_key: str) -> bool:
|
|
377
|
+
"""Endpoint-scoped coref: escalate iff the candidate's OWN endpoints are
|
|
378
|
+
unresolvable — a pronoun endpoint, or no grounded subject on a clause that
|
|
379
|
+
leads like a subject-dropped continuation. A pronoun elsewhere in the
|
|
380
|
+
sentence is conversational filler, not a resolution problem."""
|
|
381
|
+
for endpoint in (_cand_subject_name(cand), _cand_object_name(cand)):
|
|
382
|
+
if _norm(endpoint) in _ENDPOINT_PRONOUNS:
|
|
383
|
+
return True
|
|
384
|
+
subject_key = _norm(_cand_subject_name(cand))
|
|
385
|
+
grounded = (
|
|
386
|
+
getattr(cand, "subject_span", None) is not None
|
|
387
|
+
or (bool(subject_key) and subject_key == self_key)
|
|
388
|
+
)
|
|
389
|
+
if not grounded and text and (_ELLIPSIS_LEAD.match(text) or _LEADING_VERB.match(text)):
|
|
390
|
+
return True
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
# NOTE (Task 6, 2026-07): `_references_prior_entity` was deleted here. It escalated
|
|
394
|
+
# any candidate whose subject was a previously-seen entity; on real dialogs subject
|
|
395
|
+
# re-mention is normal discourse (52.8% of candidates), so it blocked the <20% gate
|
|
396
|
+
# with no recall benefit (entity linking is deterministic by name). Ambiguous
|
|
397
|
+
# references still escalate via `_coref_or_ellipsis`, inferential edges via
|
|
398
|
+
# `_is_possible_derives`. `_cand_introduced_here` is now unused by the router but
|
|
399
|
+
# kept as a public helper; `known_entities` remains a `route()` parameter for the
|
|
400
|
+
# typer's context (see route()'s docstring).
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _is_possible_derives(cand: Candidate, text: str) -> bool:
|
|
404
|
+
"""Heuristic for an inferential edge: an inference cue word, OR a predicate that
|
|
405
|
+
isn't in our confidently-typeable set (unknown/empty ⇒ the LLM must type it)."""
|
|
406
|
+
predicate = _cand_predicate(cand)
|
|
407
|
+
if not predicate or predicate not in _KNOWN_PREDICATES:
|
|
408
|
+
return True
|
|
409
|
+
if text and _INFERENCE_CUES.search(text):
|
|
410
|
+
return True
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
# ── internals ──
|
|
414
|
+
def _accumulate(self, stats: RouteStats) -> None:
|
|
415
|
+
self._cum.seen += stats.seen
|
|
416
|
+
self._cum.escalated += stats.escalated
|
|
417
|
+
for reason, n in stats.by_reason.items():
|
|
418
|
+
self._cum.by_reason[reason] = self._cum.by_reason.get(reason, 0) + n
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Phase 0 extractor: rules only (regex + dateparser). No GLiNER2, no LLM.
|
|
2
|
+
|
|
3
|
+
The spec defers GLiNER2 to Phase 1 and the LLM-typing residual to Phase 1+. Phase 0
|
|
4
|
+
proves the spine end-to-end, so this extractor is deliberately simple and fully
|
|
5
|
+
deterministic: it turns an episode into one-or-more atomic facts with a parsed
|
|
6
|
+
`valid_at`, a coarse subject/predicate, and the standalone sentence as `fact_text`.
|
|
7
|
+
|
|
8
|
+
It is NOT meant to be good extraction — it is the reproducible candidate-generation
|
|
9
|
+
stub that the Phase 1 hybrid pipeline replaces. Each emitted fact is a standalone
|
|
10
|
+
sentence (matching supermemory's "atomic, standalone" rule), keyed off simple
|
|
11
|
+
subject-verb-object heuristics.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from dateutil import parser as dateparser
|
|
21
|
+
|
|
22
|
+
from ..types import Episode, Fact, new_id, now_ms
|
|
23
|
+
|
|
24
|
+
# Very small relation lexicon → normalized predicate slot. Phase 1 replaces this
|
|
25
|
+
# with GLiNER2 schema relations + LLM typing.
|
|
26
|
+
_PREDICATE_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
27
|
+
(re.compile(r"\b(works?|working)\s+(at|for)\b", re.I), "works_at"),
|
|
28
|
+
(re.compile(r"\b(lives?|living|based)\s+in\b", re.I), "lives_in"),
|
|
29
|
+
(re.compile(r"\b(likes?|loves?|enjoys?|prefers?)\b", re.I), "likes"),
|
|
30
|
+
(re.compile(r"\b(dislikes?|hates?)\b", re.I), "dislikes"),
|
|
31
|
+
(re.compile(r"\b(is|am|are)\s+(a|an)\b", re.I), "is_a"),
|
|
32
|
+
(re.compile(r"\b(has|have|owns?)\b", re.I), "has"),
|
|
33
|
+
(re.compile(r"\b(uses?|using)\b", re.I), "uses"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
_FIRST_PERSON = re.compile(r"\b(I|I'm|I am|my|me|mine)\b", re.I)
|
|
37
|
+
_SENT_SPLIT = re.compile(r"(?<=[.!?])\s+")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ExtractedFact:
|
|
42
|
+
"""An extractor's pre-persistence candidate (before entity resolution)."""
|
|
43
|
+
|
|
44
|
+
subject_name: str
|
|
45
|
+
predicate: str
|
|
46
|
+
fact_text: str
|
|
47
|
+
valid_at: int
|
|
48
|
+
object_literal: Optional[str] = None
|
|
49
|
+
confidence: float = 0.6 # rules-only → modest confidence; LLM typing would lift this
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RulesExtractor:
|
|
53
|
+
def __init__(self, default_subject: str = "user") -> None:
|
|
54
|
+
self.default_subject = default_subject
|
|
55
|
+
|
|
56
|
+
def extract(self, episode: Episode) -> list[ExtractedFact]:
|
|
57
|
+
out: list[ExtractedFact] = []
|
|
58
|
+
for sentence in _split_sentences(episode.raw):
|
|
59
|
+
valid_at = self._resolve_time(sentence, episode.t_ref)
|
|
60
|
+
predicate = self._match_predicate(sentence)
|
|
61
|
+
if predicate is None:
|
|
62
|
+
continue
|
|
63
|
+
subject = self.default_subject if _FIRST_PERSON.search(sentence) else _lead_noun(sentence)
|
|
64
|
+
out.append(
|
|
65
|
+
ExtractedFact(
|
|
66
|
+
subject_name=subject,
|
|
67
|
+
predicate=predicate,
|
|
68
|
+
fact_text=sentence.strip(),
|
|
69
|
+
valid_at=valid_at,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
def _match_predicate(self, sentence: str) -> Optional[str]:
|
|
75
|
+
for pat, slot in _PREDICATE_PATTERNS:
|
|
76
|
+
if pat.search(sentence):
|
|
77
|
+
return slot
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def _resolve_time(self, sentence: str, t_ref: int) -> int:
|
|
81
|
+
"""Parse an explicit date in the sentence; else fall back to the episode t_ref.
|
|
82
|
+
Deterministic: dateparser with a fixed default anchored at t_ref."""
|
|
83
|
+
try:
|
|
84
|
+
default = _ms_to_dt(t_ref)
|
|
85
|
+
dt = dateparser.parse(sentence, fuzzy=True, default=default)
|
|
86
|
+
if dt is not None:
|
|
87
|
+
return int(dt.timestamp() * 1000)
|
|
88
|
+
except (ValueError, OverflowError, TypeError):
|
|
89
|
+
pass
|
|
90
|
+
return t_ref
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def to_fact(ef: ExtractedFact, *, namespace: str, subject_id: str, episode_id: str) -> Fact:
|
|
94
|
+
"""Bind an ExtractedFact to resolved ids → a persistable Fact."""
|
|
95
|
+
ts = now_ms()
|
|
96
|
+
return Fact(
|
|
97
|
+
id=new_id(),
|
|
98
|
+
namespace=namespace,
|
|
99
|
+
subject_id=subject_id,
|
|
100
|
+
predicate=ef.predicate,
|
|
101
|
+
object_literal=ef.object_literal,
|
|
102
|
+
fact_text=ef.fact_text,
|
|
103
|
+
valid_at=ef.valid_at,
|
|
104
|
+
episode_id=episode_id,
|
|
105
|
+
confidence=ef.confidence,
|
|
106
|
+
ingested_at=ts,
|
|
107
|
+
created_at=ts,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── helpers ──
|
|
112
|
+
def _split_sentences(text: str) -> list[str]:
|
|
113
|
+
return [s for s in _SENT_SPLIT.split(text.strip()) if s.strip()]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _lead_noun(sentence: str) -> str:
|
|
117
|
+
"""Crude subject guess: first capitalized token, else the first token. Phase 1's
|
|
118
|
+
GLiNER2 NER replaces this."""
|
|
119
|
+
for tok in sentence.split():
|
|
120
|
+
clean = tok.strip(".,!?;:'\"")
|
|
121
|
+
if clean and clean[0].isupper():
|
|
122
|
+
return clean
|
|
123
|
+
first = sentence.split()
|
|
124
|
+
return first[0].strip(".,!?;:'\"") if first else "unknown"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _ms_to_dt(ms: int):
|
|
128
|
+
from datetime import datetime, timezone
|
|
129
|
+
|
|
130
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Salience (importance) scoring at write time — spec section 6B.
|
|
2
|
+
|
|
3
|
+
The spec says importance is "rated once at write and cached" and consumed at rank
|
|
4
|
+
time as `importance = salience/10` (see Retriever's salience-decay re-score). The
|
|
5
|
+
*real* Phase >1 plan is an LLM rater that judges how memorable/consequential a fact
|
|
6
|
+
is. Phase 1 keeps it DETERMINISTIC and cheap — no LLM, no network, pure stdlib —
|
|
7
|
+
exactly like RulesExtractor is the deterministic stub for GLiNER2/LLM extraction.
|
|
8
|
+
|
|
9
|
+
This is a STUB on purpose: `score_salience` is the seam an LLM rater drops into
|
|
10
|
+
later (same signature, same [0, 10] contract). Until then it uses transparent
|
|
11
|
+
heuristics so the value is reproducible in tests and explainable in eval:
|
|
12
|
+
|
|
13
|
+
* facts with concrete grounding (dates, numbers, proper nouns) matter more —
|
|
14
|
+
"moved to Berlin on 2025-03-01" is more consequential than "likes coffee";
|
|
15
|
+
* directly asserted observations outrank inferred ones — an inference is a
|
|
16
|
+
derived guess, so it starts lower (the LLM rater would also discount these);
|
|
17
|
+
* non-user sources (assistant/tool/doc) are background context, so they get a
|
|
18
|
+
mild discount versus first-party user statements;
|
|
19
|
+
* very short / filler facts score low; longer specific facts score higher,
|
|
20
|
+
with diminishing returns so a wall of text can't dominate.
|
|
21
|
+
|
|
22
|
+
The output is clamped to [0, 10] to match the `salience REAL ... DEFAULT 0.0`
|
|
23
|
+
column and the `importance = salience/10 ∈ [0, 1]` rank-time normalization.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
# ── tunable weights (kept module-level + named so the heuristic is auditable) ──
|
|
31
|
+
# A neutral baseline so an unremarkable-but-valid fact lands mid-scale, leaving
|
|
32
|
+
# head-room for both boosts and penalties.
|
|
33
|
+
_BASELINE = 4.0
|
|
34
|
+
|
|
35
|
+
# Signals that a fact is concrete/consequential rather than vague preference talk.
|
|
36
|
+
# Each present signal adds its weight (once), so grounding compounds.
|
|
37
|
+
_DATE = re.compile(
|
|
38
|
+
r"\b\d{4}-\d{2}-\d{2}\b" # ISO date
|
|
39
|
+
r"|\b\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?\b" # 1/2 or 01/02/2025
|
|
40
|
+
r"|\b\d{1,2}:\d{2}\b" # clock time
|
|
41
|
+
r"|\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2}\b",
|
|
42
|
+
re.I,
|
|
43
|
+
)
|
|
44
|
+
_NUMBER = re.compile(r"\b\d+(?:[.,]\d+)?\b") # any standalone number/quantity
|
|
45
|
+
# Proper noun = a capitalized token that is NOT the sentence's first word. We strip
|
|
46
|
+
# the lead token before matching so "I moved" doesn't count "I" as a proper noun.
|
|
47
|
+
_PROPER_NOUN = re.compile(r"(?<!^)\b[A-Z][a-zA-Z]+\b")
|
|
48
|
+
|
|
49
|
+
_DATE_BOOST = 1.6
|
|
50
|
+
_NUMBER_BOOST = 0.9
|
|
51
|
+
_PROPER_NOUN_BOOST = 1.2
|
|
52
|
+
|
|
53
|
+
# Penalties.
|
|
54
|
+
# Inferred facts are derived guesses (Fact.is_inference / relation 'derives'); the
|
|
55
|
+
# LLM rater would trust them less, so the deterministic stub mirrors that prior.
|
|
56
|
+
_INFERENCE_PENALTY = 2.0
|
|
57
|
+
# First-party user statements are the primary signal; everything else is context.
|
|
58
|
+
_NON_USER_PENALTY = 1.0
|
|
59
|
+
|
|
60
|
+
# Length shaping: reward specificity up to a point, then flatten so verbosity alone
|
|
61
|
+
# can't win. Word counts (cheap, locale-free) are good enough for a stub.
|
|
62
|
+
_FILLER_MAX_WORDS = 3 # "ok thanks", "yes" → near-floor importance
|
|
63
|
+
_FILLER_PENALTY = 2.5
|
|
64
|
+
_LEN_BONUS_PER_WORD = 0.18
|
|
65
|
+
_LEN_BONUS_CAP = 1.8 # reached around ~10 content words
|
|
66
|
+
|
|
67
|
+
# Score bounds match the DB column + importance=salience/10 normalization.
|
|
68
|
+
_MIN, _MAX = 0.0, 10.0
|
|
69
|
+
|
|
70
|
+
_PROPER_NOUN_LEAD = re.compile(r"^\W*\w+\s*") # drop leading token for proper-noun test
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def score_salience(fact_text: str, *, source: str, is_inference: bool) -> float:
|
|
74
|
+
"""Rate how important/memorable a fact is, in [0, 10]. Deterministic stub.
|
|
75
|
+
|
|
76
|
+
Cheap, pure-stdlib heuristic computed once at write and cached in
|
|
77
|
+
`Fact.salience` (the Retriever later reads it as `importance = salience/10`).
|
|
78
|
+
Replaceable by an LLM rater with this exact signature and range.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
fact_text: the standalone fact sentence (what gets embedded/ranked).
|
|
82
|
+
source: provenance of the originating episode — 'user'|'assistant'|'tool'|'doc'.
|
|
83
|
+
User statements are first-party; other sources are discounted slightly.
|
|
84
|
+
is_inference: True for derived/inferred facts (relation 'derives'); these
|
|
85
|
+
start lower because they are guesses, not direct observations.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A float in [0.0, 10.0]. Empty/whitespace text scores 0.0.
|
|
89
|
+
"""
|
|
90
|
+
text = (fact_text or "").strip()
|
|
91
|
+
if not text:
|
|
92
|
+
# Nothing to weigh — degenerate input shouldn't crash the write path.
|
|
93
|
+
return _MIN
|
|
94
|
+
|
|
95
|
+
score = _BASELINE
|
|
96
|
+
|
|
97
|
+
# ── grounding boosts (concrete > vague) ──
|
|
98
|
+
if _DATE.search(text):
|
|
99
|
+
score += _DATE_BOOST
|
|
100
|
+
if _NUMBER.search(text):
|
|
101
|
+
score += _NUMBER_BOOST
|
|
102
|
+
# Test proper nouns on the text minus its leading token so a leading capital
|
|
103
|
+
# (sentence start, "I") doesn't masquerade as a named entity.
|
|
104
|
+
if _PROPER_NOUN.search(_PROPER_NOUN_LEAD.sub("", text, count=1)):
|
|
105
|
+
score += _PROPER_NOUN_BOOST
|
|
106
|
+
|
|
107
|
+
# ── length / specificity shaping ──
|
|
108
|
+
n_words = len(text.split())
|
|
109
|
+
if n_words <= _FILLER_MAX_WORDS:
|
|
110
|
+
# Short acknowledgements / filler carry little durable information.
|
|
111
|
+
score -= _FILLER_PENALTY
|
|
112
|
+
else:
|
|
113
|
+
# Diminishing reward for elaboration; capped so length alone can't dominate.
|
|
114
|
+
score += min(_LEN_BONUS_CAP, (n_words - _FILLER_MAX_WORDS) * _LEN_BONUS_PER_WORD)
|
|
115
|
+
|
|
116
|
+
# ── provenance / inference penalties ──
|
|
117
|
+
if is_inference:
|
|
118
|
+
score -= _INFERENCE_PENALTY
|
|
119
|
+
if source != "user":
|
|
120
|
+
score -= _NON_USER_PENALTY
|
|
121
|
+
|
|
122
|
+
# Clamp to the cached-salience contract [0, 10].
|
|
123
|
+
return max(_MIN, min(_MAX, score))
|