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.