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,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))