diff --git a/README.md b/README.md index cba9b39..a69ef4d 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ resolved by name via entry points or a built-in default. | 7 | Payments | `Payments` | `prepaid_credits` (in-memory ledger) | | 8 | Coordination | `Coordination` | `contract_net` (FIPA: propose · bid · resolve · commit) | | 9 | Negotiation | `Negotiation` | `alternating_offers` (Rubinstein, with patience discount) | -| 10 | Memory | `Memory` | `blackboard` (shared KV, subscribe, CAS) | +| 10 | Memory | `Memory` | `blackboard` (shared KV, subscribe, CAS); also ships `semantic` (similarity recall + TTL + LRU) | | 11 | Privacy | `Privacy` | `noop` (stub passthrough) | | 12 | Data Facts | `DataFacts` | `datafacts_v1` (dataset publish · fetch · ACL) | diff --git a/docs/layers/memory.md b/docs/layers/memory.md index 2fed6a1..12aac29 100644 --- a/docs/layers/memory.md +++ b/docs/layers/memory.md @@ -15,11 +15,41 @@ class Memory(Protocol): Full definition: [`nest_core/layers/memory.py`](../../packages/nest-core/nest_core/layers/memory.py). -## Default plugin +## Built-in plugins -`blackboard` — shared in-process dict with subscribe + CAS. +| Name | What it is | When to use | +|---|---|---| +| `blackboard` (default) | Shared in-process dict with subscribe + CAS. | Coordination via shared state; you already know the key you want. | +| `semantic` | Drop-in `Memory` with **similarity recall**, **TTL**, and **LRU eviction**. Deterministic hashed-trigram embedder, no API key required. | Retrieval-augmented LLM agents; stressing what gets remembered vs. evicted under capacity bounds. | -Source: [`nest_plugins_reference/memory/blackboard.py`](../../packages/nest-plugins-reference/nest_plugins_reference/memory/blackboard.py). +Sources: +- [`nest_plugins_reference/memory/blackboard.py`](../../packages/nest-plugins-reference/nest_plugins_reference/memory/blackboard.py) +- [`nest_plugins_reference/memory/semantic.py`](../../packages/nest-plugins-reference/nest_plugins_reference/memory/semantic.py) + +### `semantic` — extra surface + +Implements the full `Memory` protocol so it slots in anywhere +`blackboard` does. On top of that: + +```python +mem = SemanticMemory(capacity=128, ttl=100) +await mem.write("buyer-3:greet", b"hello, I want to buy apples") +[hit] = await mem.recall("apple buyer", k=1) +hit.key # "buyer-3:greet" +hit.score # cosine similarity, in [-1, 1] +await mem.forget("buyer-3:greet") +mem.stats() # {size, capacity, writes, recalls, evictions, expirations, tick} +``` + +The embedder is a deterministic hashed bag of (tokens + character +trigrams) — so `recall("apple")` finds memories mentioning "apples" +without any external service and without breaking NEST's "same seed +→ identical trace" guarantee. + +For a learned embedding model, wrap the OpenAI/Anthropic embeddings +API behind the same surface and register it as a separate plugin +(e.g. `memory:openai_embeddings`). That one will *not* be +deterministic — keep it for Tier 2 only. ## Writing your own @@ -27,4 +57,5 @@ See [`writing-a-plugin.md`](../writing-a-plugin.md). Register under entry point group `nest.plugins.memory`. Good fits to test here: CRDTs (LWW-Register, OR-Set), tuple spaces, -eventually-consistent stores, snapshot isolation. +eventually-consistent stores, snapshot isolation, vector stores +(FAISS / pgvector / Chroma) behind the `semantic` surface. diff --git a/packages/nest-core/nest_core/plugins.py b/packages/nest-core/nest_core/plugins.py index 8a4d76e..df6f0fc 100644 --- a/packages/nest-core/nest_core/plugins.py +++ b/packages/nest-core/nest_core/plugins.py @@ -29,6 +29,7 @@ f"{_REF}.negotiation.alternating_offers:AlternatingOffers" ), ("memory", "blackboard"): f"{_REF}.memory.blackboard:Blackboard", + ("memory", "semantic"): f"{_REF}.memory.semantic:SemanticMemory", ("privacy", "noop"): f"{_REF}.privacy.noop:NoopPrivacy", ("datafacts", "datafacts_v1"): f"{_REF}.datafacts.datafacts_v1:DataFactsV1", } diff --git a/packages/nest-plugins-reference/nest_plugins_reference/memory/__init__.py b/packages/nest-plugins-reference/nest_plugins_reference/memory/__init__.py index 9881313..da60aac 100644 --- a/packages/nest-plugins-reference/nest_plugins_reference/memory/__init__.py +++ b/packages/nest-plugins-reference/nest_plugins_reference/memory/__init__.py @@ -1 +1,14 @@ # SPDX-License-Identifier: Apache-2.0 +"""Memory layer reference plugins. + +- ``blackboard`` (built-in default): shared KV store with subscribe/CAS. +- ``semantic``: drop-in ``Memory`` with similarity recall, TTL, and LRU + eviction. Designed for retrieval-augmented LLM agent swarms while + staying deterministic for Tier 1 simulation. +""" + +from nest_plugins_reference.memory.blackboard import Blackboard as Blackboard +from nest_plugins_reference.memory.semantic import RecallHit as RecallHit +from nest_plugins_reference.memory.semantic import SemanticMemory as SemanticMemory + +__all__ = ["Blackboard", "RecallHit", "SemanticMemory"] diff --git a/packages/nest-plugins-reference/nest_plugins_reference/memory/semantic.py b/packages/nest-plugins-reference/nest_plugins_reference/memory/semantic.py new file mode 100644 index 0000000..e295b49 --- /dev/null +++ b/packages/nest-plugins-reference/nest_plugins_reference/memory/semantic.py @@ -0,0 +1,397 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Semantic memory plugin — content-addressable recall for agent swarms. + +Implements the standard ``nest_sdk.Memory`` protocol (``read``/``write``/ +``subscribe``/``cas``) so it is a drop-in replacement for ``blackboard``, +but layers two LLM-agent-relevant capabilities on top: + +1. **Similarity recall.** ``recall(query, k)`` returns the *k* most similar + stored values to a query string, ranked by cosine similarity over a + deterministic hashed bag-of-tokens "embedding". No external embedding + service, no API key, no GPU — and crucially **byte-identical across + runs given the same writes**, which preserves NEST's Tier 1 + determinism guarantee. +2. **TTL + capacity bounds.** Memories carry a logical timestamp and can + age out; once the store hits ``capacity`` the least-recently-accessed + entry is evicted. This lets you actually stress retrieval-heavy + coordination protocols ("what happens when 50 agents have to share a + memory of size 100 with a 5% drop rate?") instead of pretending memory + is infinite. + +Why this exists. The default ``blackboard`` plugin is a shared dict. +That is fine for state-machine agents that already know the key they +want, but it is the wrong shape for the thing LLM agents actually do: +"recall the most relevant past interaction given this prompt." This +plugin is the testing harness for retrieval-augmented agent +coordination — what gets remembered, what gets evicted, and how +brittle a swarm's collective memory is under load. + +Example:: + + mem = SemanticMemory(capacity=128, ttl=100) + await mem.write("buyer-3:greeting", b"hello, I want to buy apples") + await mem.write("buyer-7:greeting", b"hi, looking for bananas") + hits = await mem.recall("apple buyer", k=1) + # hits == [("buyer-3:greeting", b"hello, I want to buy apples", )] +""" + +from __future__ import annotations + +import asyncio +import math +import re +from collections import OrderedDict +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass, field + +# Public surface: ``RecallHit`` is what ``recall`` returns. It is a small +# typed tuple-like dataclass so callers can ``hit.key`` / ``hit.score`` +# instead of indexing by position. We deliberately keep this dependency-free +# (no numpy, no sklearn) — semantic memory in NEST should run anywhere the +# rest of the simulator runs. + + +# Hashed embedding dimension. Small enough to stay cheap (cosine over 256 +# floats is microseconds), large enough that collisions across a few +# thousand tokens are rare. Power of two for nice hash masking. +_DIM: int = 256 + +# Token regex: alphanumerics + apostrophes. We deliberately do not handle +# Unicode word boundaries — determinism matters more than linguistic +# correctness here. A real production embedder would belong behind a +# different plugin name; this one's whole job is to be reproducible. +_TOKEN_RE = re.compile(r"[A-Za-z0-9']+") + + +def _tokenize(text: str) -> list[str]: + """Lowercased alphanumeric tokens. Deterministic, no locale dependency.""" + return [t.lower() for t in _TOKEN_RE.findall(text)] + + +def _fnv1a(data: bytes) -> int: + """FNV-1a 64-bit hash. Process-independent (Python's hash is salted).""" + h = 0xCBF29CE484222325 + for b in data: + h ^= b + h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF + return h + + +def _features(text: str) -> list[str]: + """Token + char-trigram features for the embedder. + + Whole tokens give exact-match signal; character trigrams give + morphological / substring signal so "apples" matches "apple", + "buyer" matches "buy", etc. This is what makes ``recall("apple + buyer", k=1)`` actually find a memory that says "I want to buy + apples" instead of returning a tie at zero similarity. + + No external library, no learned embedding, still deterministic. + Production-grade similarity would belong in a separate plugin (e.g. + ``memory:openai_embeddings``); this one is the reproducible baseline. + """ + tokens = _tokenize(text) + feats: list[str] = list(tokens) + n = 3 + for tok in tokens: + # Pad so n-grams at word boundaries pick up the prefix/suffix. + padded = f"^{tok}$" + if len(padded) <= n: + feats.append(padded) + continue + feats.extend(padded[i : i + n] for i in range(len(padded) - n + 1)) + return feats + + +def _embed(text: str) -> list[float]: + """Deterministic hashed feature vector, L2-normalized. + + Folds every (token + char-trigram) feature into ``_DIM`` buckets via + FNV-1a so the same input always produces the same vector — across + processes, machines, and Python versions. + """ + vec = [0.0] * _DIM + for feat in _features(text): + h = _fnv1a(feat.encode("utf-8")) + idx = h & (_DIM - 1) + # Sign bit so unrelated features can cancel rather than only add — + # this is the standard "signed feature hashing" trick. + sign = 1.0 if (h >> 63) & 1 else -1.0 + vec[idx] += sign + norm = math.sqrt(sum(v * v for v in vec)) + if norm == 0.0: + return vec + return [v / norm for v in vec] + + +def _cosine(a: list[float], b: list[float]) -> float: + """Cosine similarity for already-L2-normalized vectors == dot product.""" + return sum(x * y for x, y in zip(a, b, strict=True)) + + +def _decode(value: bytes) -> str: + """Best-effort UTF-8 decode for embedding; falls back to repr of bytes.""" + try: + return value.decode("utf-8") + except UnicodeDecodeError: + # Binary payload: index by its hex digest so identical bytes still + # match exactly via recall, just without semantic structure. This + # keeps the contract: every write is recallable. + return value.hex() + + +@dataclass +class RecallHit: + """A single recall result. + + Example:: + + for hit in await mem.recall("apple", k=3): + print(hit.key, hit.score) + """ + + key: str + value: bytes + score: float + + +@dataclass +class _Entry: + """Internal: stored memory record.""" + + value: bytes + embedding: list[float] + written_at: int + last_used: int = 0 + text: str = "" + forgotten: bool = field(default=False) + + +class SemanticMemory: + """Drop-in ``Memory`` plugin with similarity recall, TTL, and LRU eviction. + + Constructor parameters: + + - ``capacity``: max entries retained. ``None`` means unbounded. + - ``ttl``: logical-time-to-live. ``None`` means entries never expire. + The clock is *logical* (an integer tick incremented on every write + and recall) so behaviour is deterministic and independent of + wall-clock time. Pass a custom ``now_fn`` to drive the clock from + an external simulator if you want. + - ``now_fn``: optional callable returning the current logical time. + Use this to share a clock with the NEST simulator. If ``None``, an + internal monotonically-increasing counter is used. + + Example:: + + mem = SemanticMemory(capacity=64, ttl=50) + await mem.write("note-1", b"agent alpha proposed price 42") + await mem.write("note-2", b"agent beta accepted at 40") + # Find the most relevant memory to a new prompt: + [hit] = await mem.recall("what did beta say about price?", k=1) + assert hit.key == "note-2" + """ + + def __init__( + self, + capacity: int | None = None, + ttl: int | None = None, + now_fn: Callable[[], int] | None = None, + ) -> None: + if capacity is not None and capacity <= 0: + raise ValueError("capacity must be positive or None") + if ttl is not None and ttl <= 0: + raise ValueError("ttl must be positive or None") + self._capacity = capacity + self._ttl = ttl + # OrderedDict preserves insertion order; we ``move_to_end`` on access + # to get LRU behaviour for free. + self._store: OrderedDict[str, _Entry] = OrderedDict() + self._subscribers: dict[str, list[asyncio.Queue[bytes]]] = {} + # Bookkeeping for stats. + self._writes: int = 0 + self._evictions: int = 0 + self._expirations: int = 0 + self._recalls: int = 0 + # Logical clock. External clocks override; otherwise we tick + # internally on each write/recall. + self._tick: int = 0 + self._now_fn = now_fn + + # ------------------------------------------------------------------ + # Memory protocol surface — read / write / subscribe / cas + # ------------------------------------------------------------------ + + async def read(self, key: str) -> bytes | None: + """Read by exact key. Returns ``None`` if absent or expired. + + Reading an entry refreshes its LRU position but does **not** + reset its TTL — TTL is anchored to write time, so a hot entry + still ages out eventually. That matches how production retrieval + stores usually want it: don't let "popular but stale" memories + squat indefinitely. + """ + self._sweep_expired() + entry = self._store.get(key) + if entry is None: + return None + entry.last_used = self._now() + self._store.move_to_end(key) + return entry.value + + async def write(self, key: str, value: bytes) -> None: + """Write a value for a key and index it for similarity recall. + + Overwriting an existing key updates both the value and the + embedding, and resets the entry's TTL (it counts as a fresh + write). Subscribers are notified after the write commits. + """ + text = _decode(value) + now = self._now(advance=True) + entry = _Entry( + value=value, + embedding=_embed(text), + written_at=now, + last_used=now, + text=text, + ) + self._store[key] = entry + self._store.move_to_end(key) + self._writes += 1 + self._enforce_capacity() + # Notify subscribers after structural mutations so they see a + # consistent store if they re-read. + for q in self._subscribers.get(key, []): + await q.put(value) + + async def subscribe(self, key: str) -> AsyncIterator[bytes]: + """Subscribe to changes for a key. Yields each new value. + + Mirrors ``Blackboard.subscribe`` so swap-in is transparent. + """ + q: asyncio.Queue[bytes] = asyncio.Queue() + self._subscribers.setdefault(key, []).append(q) + try: + while True: + yield await q.get() + finally: + self._subscribers[key].remove(q) + + async def cas(self, key: str, expected: bytes, new: bytes) -> bool: + """Compare-and-swap. Updates only if current value matches expected. + + Expired entries count as absent — a CAS against an expired key + with ``expected != None`` will fail. This is intentional: TTL + eviction is a real event that races with writers, and a CAS + should respect it. + """ + self._sweep_expired() + entry = self._store.get(key) + if entry is None or entry.value != expected: + return False + await self.write(key, new) + return True + + # ------------------------------------------------------------------ + # Semantic surface — the reason this plugin exists + # ------------------------------------------------------------------ + + async def recall( + self, + query: str, + k: int = 5, + min_score: float = 0.0, + ) -> list[RecallHit]: + """Return up to ``k`` entries most similar to ``query``. + + Results are sorted by descending cosine similarity. Ties are + broken by key (lexicographic) so output is deterministic + regardless of dict iteration order — important for trace + reproducibility. + + ``min_score`` filters out weak matches (default 0.0 keeps any + non-orthogonal hit). For LLM-agent scenarios a threshold of + ~0.15 tends to drop pure-junk hits without losing recall. + + Recalling refreshes the LRU position of each returned entry so + "useful" memories survive eviction longer than dead weight. + """ + if k <= 0: + return [] + self._sweep_expired() + self._recalls += 1 + q_emb = _embed(query) + scored: list[tuple[float, str, _Entry]] = [] + for key, entry in self._store.items(): + score = _cosine(q_emb, entry.embedding) + if score >= min_score: + scored.append((score, key, entry)) + # Sort: highest score first, then key ascending for tie-breaks. + scored.sort(key=lambda t: (-t[0], t[1])) + top = scored[:k] + now = self._now() + hits: list[RecallHit] = [] + for score, key, entry in top: + entry.last_used = now + # Move recalled entries to LRU "fresh" end so they don't get + # evicted while still being useful. + self._store.move_to_end(key) + hits.append(RecallHit(key=key, value=entry.value, score=score)) + return hits + + async def forget(self, key: str) -> bool: + """Explicitly evict ``key``. Returns ``True`` if it was present.""" + return self._store.pop(key, None) is not None + + def stats(self) -> dict[str, int]: + """Snapshot of internal counters. Useful in tests + validators. + + Example:: + + assert mem.stats()["evictions"] == 0 + """ + self._sweep_expired() + return { + "size": len(self._store), + "capacity": self._capacity if self._capacity is not None else -1, + "writes": self._writes, + "recalls": self._recalls, + "evictions": self._evictions, + "expirations": self._expirations, + "tick": self._tick, + } + + # ------------------------------------------------------------------ + # Internal: clock, eviction, expiry + # ------------------------------------------------------------------ + + def _now(self, advance: bool = False) -> int: + if self._now_fn is not None: + return int(self._now_fn()) + if advance: + self._tick += 1 + return self._tick + + def _enforce_capacity(self) -> None: + if self._capacity is None: + return + while len(self._store) > self._capacity: + # OrderedDict.popitem(last=False) -> LRU end (the least + # recently used; we move-to-end on access so the front is LRU). + self._store.popitem(last=False) + self._evictions += 1 + + def _sweep_expired(self) -> None: + """Drop entries whose TTL has elapsed against the current clock.""" + if self._ttl is None: + return + now = self._now() + # Walk in insertion order; we cannot mutate during iteration so + # collect first. The store is bounded by ``capacity`` so this is + # O(n) on a small n. + stale: list[str] = [ + key for key, entry in self._store.items() if now - entry.written_at >= self._ttl + ] + for key in stale: + del self._store[key] + self._expirations += 1 diff --git a/packages/nest-plugins-reference/tests/test_plugins.py b/packages/nest-plugins-reference/tests/test_plugins.py index 1db36ea..353f6bf 100644 --- a/packages/nest-plugins-reference/tests/test_plugins.py +++ b/packages/nest-plugins-reference/tests/test_plugins.py @@ -446,6 +446,283 @@ async def test_cas_failure(self) -> None: assert await bb.read("x") == b"current" +# --------------------------------------------------------------------------- +# 10b. Memory: semantic (similarity recall + TTL + LRU) +# --------------------------------------------------------------------------- + + +class TestSemanticMemory: + @pytest.mark.asyncio + async def test_read_write_matches_blackboard_contract(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory() + assert await mem.read("k") is None + await mem.write("k", b"v") + assert await mem.read("k") == b"v" + + @pytest.mark.asyncio + async def test_cas_success_and_failure(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory() + await mem.write("x", b"old") + assert await mem.cas("x", b"old", b"new") is True + assert await mem.read("x") == b"new" + assert await mem.cas("x", b"wrong", b"newer") is False + assert await mem.read("x") == b"new" + + @pytest.mark.asyncio + async def test_subscribe_receives_writes(self) -> None: + import asyncio + + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory() + received: list[bytes] = [] + + async def listener() -> None: + async for v in mem.subscribe("topic"): + received.append(v) + if len(received) >= 2: + break + + task = asyncio.create_task(listener()) + # Yield so the subscriber has time to register before we write. + await asyncio.sleep(0) + await mem.write("topic", b"first") + await mem.write("topic", b"second") + await asyncio.wait_for(task, timeout=1.0) + assert received == [b"first", b"second"] + + @pytest.mark.asyncio + async def test_recall_ranks_relevant_first(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory() + await mem.write("buyer-3:greet", b"hello, I want to buy apples") + await mem.write("buyer-7:greet", b"hi, looking for bananas") + await mem.write("buyer-9:greet", b"howdy, after some pears") + + hits = await mem.recall("apple buyer", k=1) + assert len(hits) == 1 + assert hits[0].key == "buyer-3:greet" + assert hits[0].score > 0.0 + + @pytest.mark.asyncio + async def test_recall_top_k_sorted_descending(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory() + await mem.write("a", b"apples bananas") + await mem.write("b", b"apples oranges pears") + await mem.write("c", b"unrelated zebra giraffe") + + hits = await mem.recall("apples", k=3) + scores = [h.score for h in hits] + assert scores == sorted(scores, reverse=True) + # Best two should be the apple-mentioning ones. + assert {hits[0].key, hits[1].key} == {"a", "b"} + + @pytest.mark.asyncio + async def test_recall_is_deterministic_across_runs(self) -> None: + # Determinism is a load-bearing NEST property; semantic recall has + # to satisfy it as well as exact-key reads do. + from nest_plugins_reference.memory.semantic import SemanticMemory + + def make() -> SemanticMemory: + return SemanticMemory() + + async def populate(mem: SemanticMemory) -> None: + for i, text in enumerate( + [ + b"the auction closed at price forty two", + b"buyer accepted the offer of forty", + b"seller withdrew due to low bid", + b"observer noted nothing unusual", + ] + ): + await mem.write(f"note-{i}", text) + + m1, m2 = make(), make() + await populate(m1) + await populate(m2) + h1 = await m1.recall("what price was accepted?", k=4) + h2 = await m2.recall("what price was accepted?", k=4) + # Byte-identical results, score-for-score, key-for-key. + assert [(h.key, h.value, h.score) for h in h1] == [(h.key, h.value, h.score) for h in h2] + + @pytest.mark.asyncio + async def test_recall_min_score_filters_weak_matches(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory() + await mem.write("rel", b"apples and oranges") + await mem.write("irr", b"zzz qqq xxx") + # Threshold high enough to drop the unrelated (zero-overlap) one. + # 0.15 is a defensible default for the hashed-trigram embedder: it + # keeps hits with any meaningful substring overlap, drops pure noise. + hits = await mem.recall("apple", k=5, min_score=0.15) + assert len(hits) == 1 + assert hits[0].key == "rel" + + @pytest.mark.asyncio + async def test_recall_k_zero_returns_empty(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory() + await mem.write("x", b"hello") + assert await mem.recall("hello", k=0) == [] + + @pytest.mark.asyncio + async def test_capacity_evicts_lru(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory(capacity=2) + await mem.write("a", b"first") + await mem.write("b", b"second") + # Touch a so it becomes most-recently-used. + await mem.read("a") + await mem.write("c", b"third") + # b was LRU and should have been evicted. + assert await mem.read("b") is None + assert await mem.read("a") == b"first" + assert await mem.read("c") == b"third" + assert mem.stats()["evictions"] == 1 + + @pytest.mark.asyncio + async def test_recall_protects_from_eviction(self) -> None: + # Recall should refresh LRU position for hits so "useful" memories + # survive longer than dead weight under capacity pressure. + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory(capacity=2) + await mem.write("a", b"apples") + await mem.write("b", b"bananas") + # Recall a: it's now most-recently-used. + await mem.recall("apples", k=1) + await mem.write("c", b"cherries") + # b (oldest unused) should be evicted, not a. + assert await mem.read("a") == b"apples" + assert await mem.read("b") is None + + @pytest.mark.asyncio + async def test_ttl_expires_entries(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + # Drive the clock externally so we can simulate elapsed time. + clock = {"t": 0} + mem = SemanticMemory(ttl=10, now_fn=lambda: clock["t"]) + await mem.write("x", b"hello") + clock["t"] = 5 + assert await mem.read("x") == b"hello" + clock["t"] = 10 + # TTL elapsed: entry should be swept out. + assert await mem.read("x") is None + assert mem.stats()["expirations"] == 1 + + @pytest.mark.asyncio + async def test_overwrite_resets_ttl(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + clock = {"t": 0} + mem = SemanticMemory(ttl=10, now_fn=lambda: clock["t"]) + await mem.write("x", b"v1") + clock["t"] = 9 + await mem.write("x", b"v2") + clock["t"] = 18 + # Original write would have expired at t=10; the rewrite at t=9 + # pushes expiry to t=19, so the entry is still there at t=18. + assert await mem.read("x") == b"v2" + + @pytest.mark.asyncio + async def test_recall_skips_expired(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + clock = {"t": 0} + mem = SemanticMemory(ttl=5, now_fn=lambda: clock["t"]) + await mem.write("old", b"apples in storage") + clock["t"] = 3 + await mem.write("new", b"apples fresh today") + clock["t"] = 6 + # "old" has expired; "new" is still fresh. + hits = await mem.recall("apples", k=5) + keys = [h.key for h in hits] + assert "old" not in keys + assert "new" in keys + + @pytest.mark.asyncio + async def test_forget_removes_entry(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory() + await mem.write("x", b"v") + assert await mem.forget("x") is True + assert await mem.read("x") is None + # Idempotent: second forget returns False. + assert await mem.forget("x") is False + + @pytest.mark.asyncio + async def test_stats_tracks_activity(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory(capacity=2) + await mem.write("a", b"x") + await mem.write("b", b"y") + await mem.write("c", b"z") # triggers one eviction + await mem.recall("x", k=1) + s = mem.stats() + assert s["size"] == 2 + assert s["capacity"] == 2 + assert s["writes"] == 3 + assert s["evictions"] == 1 + assert s["recalls"] == 1 + + @pytest.mark.asyncio + async def test_binary_payloads_round_trip(self) -> None: + # Non-UTF8 bytes shouldn't crash the embedder. + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory() + payload = bytes(range(256)) + await mem.write("blob", payload) + assert await mem.read("blob") == payload + # Recall still finds it (hex-indexed; identical bytes = exact match). + hits = await mem.recall(payload.hex(), k=1) + assert hits[0].key == "blob" + + def test_invalid_capacity_rejected(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + with pytest.raises(ValueError, match="capacity"): + SemanticMemory(capacity=0) + + def test_invalid_ttl_rejected(self) -> None: + from nest_plugins_reference.memory.semantic import SemanticMemory + + with pytest.raises(ValueError, match="ttl"): + SemanticMemory(ttl=-1) + + def test_satisfies_memory_protocol(self) -> None: + # Structural typing check: SemanticMemory should be a drop-in + # replacement for Blackboard wherever the Memory protocol is used. + from nest_core.layers.memory import Memory + from nest_plugins_reference.memory.semantic import SemanticMemory + + mem = SemanticMemory() + assert isinstance(mem, Memory) + + def test_registered_as_builtin_plugin(self) -> None: + # The plugin registry should resolve ``memory:semantic`` to the + # SemanticMemory class so YAML scenarios can use it by name. + from nest_core.plugins import PluginRegistry + from nest_plugins_reference.memory.semantic import SemanticMemory + + reg = PluginRegistry() + cls = reg.resolve("memory", "semantic") + assert cls is SemanticMemory + + # --------------------------------------------------------------------------- # 11. Privacy: noop # ---------------------------------------------------------------------------