Known Issues & Caveats

On this page

This page collects every behavior discovered during static analysis that looks unintended, inconsistent, or would prevent the documented API from working exactly as designed. Each item links back to the API page where it's discussed in context. Nothing here is speculative — every claim is traceable to a specific line or pattern in the source, as described.

Blocking / functional issues

CachedEmbedder calls a method providers don't define

CachedEmbedder.embed_batch() calls await self._provider.aembed(batch), but BaseEmbeddingProvider (and all four concrete providers: MockEmbeddingProvider, OpenAIEmbeddingProvider, HuggingFaceEmbeddingProvider, OllamaEmbeddingProvider) define aencode(), not aembed(). This means embed_batch() — and therefore embed_single(), and therefore both CacheManager.get() and CacheManager.put() — will raise AttributeError as soon as they reach the embedding step, for every configured provider, including the default mock one.

Where documented: embedding_index

Practical workaround if you need to run this code today: monkey-patch or subclass CachedEmbedder/the provider classes to rename aembed to aencode (or add an aembed alias method), or call provider.encode(texts) synchronously yourself and skip CachedEmbedder for a quick test.

OllamaEmbeddingProvider is not functional

OllamaEmbeddingProvider.__init__ never sets self._httpx, self._url, or self._timeout, but encode() references all three. Any call to encode() on this provider raises AttributeError. Additionally, its constructor's self._model ends up holding an OllamaEmbedder instance (from the external zerda.embeddings module), while encode()'s request payload uses self._model as if it were a model-name string (json={"model": self._model, ...}) — a second inconsistency independent of the missing attributes.

Where documented: embedding_index

Non-mock embedding providers depend on an undeclared external package

OpenAIEmbeddingProvider, HuggingFaceEmbeddingProvider, and OllamaEmbeddingProvider all import from zerda.embeddings — a module that is not part of this codebase and whose existence/availability on PyPI cannot be verified from the analyzed source. Selecting any non-mock EmbeddingProvider will raise ImportError unless that package happens to be available in your environment.

Where documented: embedding_index, configuration

Behavioral inconsistencies

Truthiness checks on ttl_s/ttl treat 0 as "no expiry" — inconsistently across the codebase

MemoryStorage.set, SQLiteStorage.set, and RedisStorage.set all use if ttl_s: to decide whether to apply a TTL, so ttl_s=0 is treated identically to ttl_s=None (no expiry) rather than an immediate expiry. MultiLevelCache.set, by contrast, uses ttl if ttl is not None else config.default_ttl, so ttl=0 there does produce an (effectively-instantly-expired) TTL. The two halves of the codebase disagree on this edge case.

Where documented: storage/memory, storage/sqlite, storage/redis, multi_level_cache

CacheManager.get()'s semantic branch always reports SEMANTIC_HIT

Even when DecisionEngine.decide() internally classifies a candidate as RoutingDecision.EXACT_HIT (similarity ≥ 0.999), CacheManager.get()'s own return statement for that code path hard-codes decision=RoutingDecision.SEMANTIC_HIT. Only the earlier, separate L1/storage exact-key-match branches ever return RoutingDecision. EXACT_HIT from get() itself.

Where documented: cache_manager

RoutingDecision.TENANT_HIT and CacheMetricsCollector._tenant_hits are unreachable

The enum member and the corresponding metrics counter/field (tenant_hit_rate) both exist, but no code path in CacheManager (or anywhere else in the analyzed source) ever produces a TENANT_HIT decision or calls a method that increments _tenant_hits. Expect tenant_hit_rate to always read 0.0.

Where documented: config_cache, metrics

flush_tenant() also deregisters the tenant, as a side effect of its L1-scanning loop

CacheManager.flush_tenant() calls self._tenant_mgr.deregister(tenant_id) inside its loop over matching L1 entries — meaning a tenant with at least one L1 entry gets fully deregistered (losing its quota configuration and shared-cache flags), not just cleared of cached data. A tenant with zero L1 entries (e.g. everything already evicted to storage-only) does not get deregistered by this call, making the behavior additionally non-deterministic with respect to L1 state at call time.

Where documented: cache_manager

MultiLevelCache.get() always records an L1 miss before checking any level

get() calls self.metrics.record_get(1, False) unconditionally near the top of the method, before checking L1 at all. If the lookup then succeeds — at L1, L2, or L3 — the appropriate hit is recorded in addition to that initial miss record. This means a single successful L1 get() call increments both l1_hits and l1_misses, which distorts CacheMetrics.get_hit_rate(1) (and the overall rate) relative to what "hit rate" normally means. A true L2 or L3 hit similarly always contributes one extra L1 miss that doesn't correspond to an actual attempted L1-only lookup pattern elsewhere in the class.

Where documented: multi_level_cache

FIFO and LRU eviction strategies select the same victim in this implementation

Both read "the first key in the OrderedDict". Since accesses call move_to_end() regardless of strategy (inside _access_entry, which isn't itself strategy-aware), an accessed entry is pushed to the back for both strategies alike — meaning FIFO does not actually preserve pure insertion-order eviction once any entry has been read at least once.

Where documented: multi_level_cache

IntelligentCacheEntry.eviction_score's decay term shrinks scores more for heavily-used entries

The formula multiplies the weighted composite score by decay ** max(0, usage_count - 1). Since decay < 1, this factor gets smaller as usage_count grows — so, all else equal, an entry with a high usage_count has its final eviction score pulled down more aggressively than a rarely-used entry, partially counteracting the usage_norm term (which otherwise raises the score for frequently-used entries). Whether this interaction is intentional tuning or an oversight cannot be determined from the source alone; it's flagged here so anyone tuning RLConfig.usage_decay is aware of the net effect.

Where documented: models

Two independent, asymmetric threshold-adjustment mechanisms

CacheManager.feedback() adjusts self._sim_threshold directly (by half a step on positive feedback, a full step on negative feedback), while also calling DecisionEngine.update_threshold(), which independently adjusts its own _current_threshold_bonus (by a full step in either direction). Both numbers feed into the same effective-threshold calculation inside DecisionEngine.decide() (max(cost_weight, semantic_threshold + threshold_bonus)), but move at different rates in response to the same event.

Where documented: cache_manager, cost_model.md

DecisionEngine's effective-threshold floor reuses cost_weight

decide() computes effective_threshold = max(self._cfg.cost_weight, semantic_threshold + bonus). cost_weight (default 0.40) is a utility-function weight, not a similarity value in its own right — using it as a hard floor for the similarity threshold appears to be either an intentional (if surprising) reuse of an existing config value, or an unintended copy/paste of the wrong field. Its practical effect is that the effective similarity threshold can never fall below 0.40 regardless of how the adaptive threshold or its bonus move.

Where documented: cost_model

CacheConfig contains duplicate dataclass field assignments

l1_max_items, l2_max_items, and default_ttl_s/default_ttl (as differently-named fields) each appear more than once in CacheConfig's class body with different values. In a Python @dataclass, only the last assignment in source order survives — the earlier ones are entirely discarded, silently, with no warning at class-definition or instantiation time.

Where documented: configuration, config_cache

EvictionConfig's composite weights are not threaded through to eviction_score

EvictionConfig.reward_weight/usage_weight/recency_weight mirror IntelligentCacheEntry.eviction_score's parameter names and defaults exactly, but CachePolicyLearner.eviction_ranking() calls entry.eviction_score(decay=cfg.usage_decay) without passing reward_w/usage_w/recency_w — so eviction_score always uses its own hard-coded defaults (0.50/0.30/0.20) regardless of what EvictionConfig specifies. Changing EvictionConfig's weight fields currently has no effect on eviction behavior.

Where documented: policy_learner, configuration

MultiLevelCache's async context manager doesn't run the sync cleanup logic

__aexit__ unconditionally returns False and does nothing else — it does not cancel the cleanup timer or clear L1/L2 the way the synchronous __exit__ does. Using async with MultiLevelCache(...) therefore leaves the background cleanup timer running and L1/L2 populated after the block exits, unlike the synchronous with form.

Where documented: multi_level_cache

TenantRegistration.custom_ttl_s and .metadata are accepted but never read

Both fields can be set when registering a tenant, but no other method in TenantManager or CacheManager reads them back. CacheManager.put()'s TTL always comes from its own ttl_s argument or CacheConfig.default_ttl_s, never from a tenant's custom_ttl_s.

Where documented: tenant_manager

Performance notes (not incorrect, but worth knowing)

EmbeddingIndex's NumPy fallback path is O(n) per insertion

EmbeddingIndex.add(), on the non-FAISS path, calls np.vstack([self._vectors, vec.reshape(1, -1)]), which allocates a new array and copies the entire existing matrix on every single insertion. For n sequential inserts without FAISS installed, this is O(n²) total work. Installing faiss-cpu avoids this (FAISS's IndexFlatIP.add is amortized O(1) per vector), and is recommended for any deployment expecting more than a few thousand cached entries.

Where documented: embedding_index

RedisStorage.all_keys() uses the blocking KEYS command

Rather than the non-blocking, cursor-based SCAN, all_keys() issues KEYS {prefix}*, which can block a shared Redis instance for the duration of a full keyspace scan on large databases.

Where documented: storage/redis

RedisStorage.keys_by_tenant() performs one round-trip per key

To self-heal its tenant-index set, keys_by_tenant() issues one EXISTS (and possibly SREM) call per member of the tenant's key set, on every invocation — O(n) network round-trips for a tenant with n entries.

Where documented: storage/redis

Every MultiLevelCache.CacheEntry is pickled once just to estimate its size

Unless size_bytes is passed explicitly, CacheEntry.__post_init__ calls pickle.dumps(self.value) purely to measure its length — even for values that will never actually be written to disk (an L1-only entry still pays this cost on set()).

Where documented: cache_entry

numpy's global random state is shared across concurrent rank_candidates calls

BanditArm.sample_reward() (called from CachePolicyLearner.rank_candidates) uses np.random.beta, which draws from NumPy's global RNG state. Concurrent calls from multiple threads are not using independent generators, which can affect reproducibility (though not correctness/crash-safety) under concurrent load.

Where documented: policy_learner

A note on scope

This list reflects what static analysis of the provided source tree can surface. It is not a substitute for tests, and some items above may be deliberate design choices whose rationale isn't fully captured in comments or docstrings — they are flagged here as observations, not verdicts, so that anyone building on this library can make an informed decision about each one.

Related pages