"""检索结果缓存(TtlCache + search 接线):repo/embedding 全打桩,不连库. 覆盖任务文档 §4 的七组断言:命中零 DB、键分量矩阵、TTL 过期、LRU 逐出、空结果不缓存(gap 埋点不被吞)、代数失效、cache=None 一致性; 外加 rule_boost 哈希的确定性与 from_settings 的装配开关。 """ from __future__ import annotations from collections.abc import Iterator from typing import Any import pytest from packages.retrieval import repo from packages.retrieval import service as service_mod from packages.retrieval.cache import CacheKey, TtlCache, rule_boost_hash from packages.retrieval.rerank import ScoredChunk from packages.retrieval.service import RetrievalService, UserContext from packages.shared.config import Settings class FakeEmbedder: """满足 EmbeddingProvider Protocol 的最小桩:记录调用,返回固定向量.""" model_name = "fake" version = "v1" dimensions = 4 def __init__(self) -> None: self.calls: list[list[str]] = [] async def embed(self, texts: list[str]) -> list[list[float]]: self.calls.append(list(texts)) return [[0.25, 0.25, 0.25, 0.25] for _ in texts] class FakeSession: def __init__(self) -> None: self.commits = 0 self.rollbacks = 0 async def commit(self) -> None: self.commits += 1 async def rollback(self) -> None: self.rollbacks += 1 class FakeClock: """可手动拨动的单调时钟:TTL 断言不靠真 sleep,边界精确到微秒.""" def __init__(self) -> None: self.now = 1000.0 def __call__(self) -> float: return self.now def advance(self, seconds: float) -> None: self.now += seconds @pytest.fixture def stubbed_repo(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: """repo 的两个 IO 函数换成计数桩:``search_calls`` 就是「DB 交互次数」.""" calls: dict[str, Any] = {"search_calls": 0, "gaps": [], "results": []} async def fake_hybrid_search( session: Any, query_vec: Any, query_text: str, **kwargs: Any ) -> list[ScoredChunk]: calls["search_calls"] += 1 return list(calls["results"]) async def fake_record_gap(session: Any, query_text: str, filter_ctx: Any) -> None: calls["gaps"].append((query_text, filter_ctx)) monkeypatch.setattr(repo, "hybrid_search", fake_hybrid_search) monkeypatch.setattr(repo, "record_gap", fake_record_gap) return calls @pytest.fixture(autouse=True) def _hermetic_module_state() -> Iterator[None]: """用例结束还原模块级全局(失效代数、共享缓存实例),不污染同进程其它用例.""" saved_version = repo.INVALIDATION_VERSION yield repo.INVALIDATION_VERSION = saved_version service_mod._shared_cache = None def _scored(chunk_id: int = 1) -> ScoredChunk: return ScoredChunk( chunk_id=chunk_id, entity_id=100, chunk_type="text", text="chunk", vec_score=1.0, kw_score=0.0, rule_score=0.0, freshness_score=1.0, final_score=0.55, ) def _key(query_text: str = "q", **overrides: Any) -> CacheKey: defaults: dict[str, Any] = { "version": 1, "query_text": query_text, "top_k": 5, "body_shape": None, "occasion": None, "entity_types": (), "rule_boost": None, } defaults.update(overrides) return CacheKey.build(**defaults) def _svc(cache: TtlCache[Any] | None = None, **kwargs: Any) -> RetrievalService: return RetrievalService(FakeSession(), embedding=FakeEmbedder(), cache=cache, **kwargs) # --- 1. 同参数二连查:命中零 DB ------------------------------------------------ async def test_repeated_search_hits_cache_with_zero_db(stubbed_repo: dict[str, Any]) -> None: stubbed_repo["results"] = [_scored(1), _scored(2)] embedder = FakeEmbedder() cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=16) svc = RetrievalService(FakeSession(), embedding=embedder, cache=cache) first = await svc.search("面试穿什么") second = await svc.search("面试穿什么") assert first == second assert [c.chunk_id for c in second.results] == [1, 2] assert stubbed_repo["search_calls"] == 1 # 第二次零 DB assert embedder.calls == [["面试穿什么"]] # 第二次连 embed 都跳过 assert cache.stats() == {"hits": 1, "misses": 1, "evictions": 0, "entries": 1} # 返回值是缓存内容的拷贝:调用方原地改 list(哪怕改的是命中返回的对象) # 也不会污染缓存(红线 2) second.results.append(_scored(99)) third = await svc.search("面试穿什么") assert [c.chunk_id for c in third.results] == [1, 2] # --- 2. 键分量矩阵:任一影响结果的输入变化 → miss ----------------------------- @pytest.mark.parametrize( ("label", "query", "ctx", "top_k"), [ ("query_text", "另一个完全不同的问题", None, None), ("occasion", None, UserContext(occasion="wedding"), None), ("body_shape", None, UserContext(body_shape="pear"), None), ("entity_types", None, UserContext(entity_types=("item",)), None), ("rule_boost", None, UserContext(rule_boost={7: 0.5}), None), ("top_k", None, None, 10), ], ) async def test_any_key_component_change_is_a_miss( stubbed_repo: dict[str, Any], label: str, query: str | None, ctx: UserContext | None, top_k: int | None, ) -> None: stubbed_repo["results"] = [_scored(1)] cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=16) svc = _svc(cache) await svc.search("面试穿什么") # 填充缓存 if top_k is not None: # top_k 是 service 级参数:同缓存、不同 top_k 的另一个实例 await _svc(cache, top_k=top_k).search("面试穿什么") else: await svc.search(query or "面试穿什么", user_ctx=ctx) assert stubbed_repo["search_calls"] == 2, label # 键不同 → 重新检索 assert cache.misses == 2 and cache.hits == 0, label assert cache.stats()["entries"] == 2, label # 两个键并存,互不覆盖 # --- 3. TTL 过期 → miss(注入 clock)------------------------------------------ async def test_ttl_expiry_is_a_miss_then_refill(stubbed_repo: dict[str, Any]) -> None: stubbed_repo["results"] = [_scored(1)] clock = FakeClock() cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=16, clock=clock) svc = _svc(cache) await svc.search("q") clock.advance(60.0) # 恰好到达 TTL 边界:>= ttl 视作过期 await svc.search("q") assert stubbed_repo["search_calls"] == 2 assert cache.misses == 2 and cache.hits == 0 clock.advance(59.9) # 重新填充后未到期 → 命中 await svc.search("q") assert stubbed_repo["search_calls"] == 2 assert cache.stats() == {"hits": 1, "misses": 2, "evictions": 0, "entries": 1} # --- 4. LRU 容量超限 → 最旧被逐、计数正确 -------------------------------------- def test_lru_evicts_oldest_beyond_capacity() -> None: cache: TtlCache[int] = TtlCache(ttl_seconds=60.0, max_entries=2) cache.put(_key("a"), 1) cache.put(_key("b"), 2) cache.put(_key("c"), 3) # 超限:最旧的 a 被逐 assert cache.get(_key("a")) is None assert cache.get(_key("b")) == 2 assert cache.stats() == {"hits": 1, "misses": 1, "evictions": 1, "entries": 2} def test_lru_recency_updates_on_hit() -> None: """命中会把条目移到"最新"端:之后超限逐出的是另一个最旧条目.""" cache: TtlCache[int] = TtlCache(ttl_seconds=60.0, max_entries=2) cache.put(_key("a"), 1) cache.put(_key("b"), 2) assert cache.get(_key("a")) == 1 # a 变为最新 cache.put(_key("c"), 3) # 逐出的应是 b,不是 a assert cache.get(_key("b")) is None assert cache.get(_key("a")) == 1 assert cache.evictions == 1 def test_ttl_cache_rejects_invalid_params() -> None: with pytest.raises(ValueError, match="ttl_seconds"): TtlCache(ttl_seconds=0, max_entries=8) with pytest.raises(ValueError, match="max_entries"): TtlCache(ttl_seconds=60.0, max_entries=0) # --- 5. 空结果不缓存:gap 埋点每次都跑 ----------------------------------------- async def test_empty_results_are_not_cached(stubbed_repo: dict[str, Any]) -> None: stubbed_repo["results"] = [] session = FakeSession() cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=16) svc = RetrievalService(session, embedding=FakeEmbedder(), cache=cache) first = await svc.search("不存在的知识点") second = await svc.search("不存在的知识点") assert first.degraded and second.degraded assert stubbed_repo["search_calls"] == 2 # 每次都真检索 assert len(stubbed_repo["gaps"]) == 2 # gap 埋点不被缓存吞掉 assert cache.stats() == {"hits": 0, "misses": 2, "evictions": 0, "entries": 0} assert session.commits == 2 # 每次埋点都照常提交 # --- 6. invalidate_cache() 后同参数 → miss(代数失效)------------------------- async def test_invalidate_cache_invalidates_by_generation( stubbed_repo: dict[str, Any], ) -> None: stubbed_repo["results"] = [_scored(1)] cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=16) svc = _svc(cache) await svc.search("面试穿什么") await svc.search("面试穿什么") assert stubbed_repo["search_calls"] == 1 # 命中 service_mod.invalidate_cache() await svc.search("面试穿什么") assert stubbed_repo["search_calls"] == 2 # 代数失效 → 重新检索 await svc.search("面试穿什么") assert stubbed_repo["search_calls"] == 2 # 新代重新缓存,再次命中 def test_invalidate_cache_bumps_generation(stubbed_repo: dict[str, Any]) -> None: """自增 repo.INVALIDATION_VERSION 的既有语义保留(T12 依赖该契约).""" before = repo.INVALIDATION_VERSION service_mod.invalidate_cache() assert before + 1 == repo.INVALIDATION_VERSION # --- 7. cache=None → 行为与现在完全一致 ---------------------------------------- async def test_cache_none_searches_db_every_time(stubbed_repo: dict[str, Any]) -> None: stubbed_repo["results"] = [_scored(1)] embedder = FakeEmbedder() svc = RetrievalService(FakeSession(), embedding=embedder) # 不传 cache first = await svc.search("面试穿什么") second = await svc.search("面试穿什么") assert first == second assert stubbed_repo["search_calls"] == 2 assert len(embedder.calls) == 2 async def test_cache_none_degraded_path_records_gap_every_time( stubbed_repo: dict[str, Any], ) -> None: stubbed_repo["results"] = [] svc = RetrievalService(FakeSession(), embedding=FakeEmbedder()) await svc.search("问不到") await svc.search("问不到") assert stubbed_repo["search_calls"] == 2 assert len(stubbed_repo["gaps"]) == 2 # --- rule_boost 哈希:确定性(排序归一 + 不同值不折叠)------------------------ def test_rule_boost_hash_is_insertion_order_insensitive() -> None: d1 = {1: 0.5, 2: 0.3} d2 = {2: 0.3} d2[1] = 0.5 assert rule_boost_hash(d1) == rule_boost_hash(d2) a = {1: 0.5} a[2] = 0.3 b = {2: 0.3} b[1] = 0.5 assert CacheKey.build(version=1, query_text="q", top_k=5, rule_boost=a) == ( CacheKey.build(version=1, query_text="q", top_k=5, rule_boost=b) ) def test_rule_boost_hash_distinguishes_values_and_empty() -> None: assert rule_boost_hash({}) != rule_boost_hash({1: 0.0}) assert rule_boost_hash({1: 0.5}) != rule_boost_hash({1: 0.6}) def test_entity_types_order_does_not_change_key() -> None: """``= ANY(...)`` 与顺序无关:同集合不同顺序应共享同一条目.""" assert _key(entity_types=("item", "style")) == _key(entity_types=("style", "item")) # --- from_settings 装配:按 Settings 开/关与参数化 ----------------------------- def test_from_settings_disabled_yields_no_cache(stubbed_repo: dict[str, Any]) -> None: settings = Settings(_env_file=None, retrieval_cache_enabled=False) svc = RetrievalService.from_settings(FakeSession(), settings) assert svc._cache is None def test_from_settings_attaches_shared_cache_with_settings_params( stubbed_repo: dict[str, Any], ) -> None: settings = Settings( _env_file=None, retrieval_cache_ttl_seconds=7, retrieval_cache_max_entries=9, ) first = RetrievalService.from_settings(FakeSession(), settings) assert first._cache is not None assert first._cache.ttl_seconds == 7 assert first._cache.max_entries == 9 # 每请求实例共享同一缓存:跨请求复用是缓存的意义所在 second = RetrievalService.from_settings(FakeSession(), settings) assert second._cache is first._cache # TTL/容量变化 → 按新配置重建(进程内配置恒定,重建只在测试/热改配置发生) changed = Settings(_env_file=None, retrieval_cache_ttl_seconds=1, retrieval_cache_max_entries=9) third = RetrievalService.from_settings(FakeSession(), changed) assert third._cache is not None assert third._cache is not first._cache assert third._cache.ttl_seconds == 1