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