advalcache 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.
@@ -0,0 +1,211 @@
1
+ """advalcache/decorators.py — @cache.cached and @cache.memoize descriptors.
2
+
3
+ These are the primary integration point for most users. They are attached to
4
+ a Cache (or AsyncCache) instance and wrap the caller's function transparently.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import functools
11
+ import inspect
12
+ import reprlib
13
+ from typing import Any, Callable, Optional, TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ # Avoid circular import at runtime
17
+ from advalcache._engine import AdValEngine, AsyncAdValEngine
18
+
19
+ # reprlib limits repr length so huge args don't blow out key size
20
+ _repr = reprlib.Repr()
21
+ _repr.maxstring = 128
22
+ _repr.maxother = 64
23
+
24
+
25
+ def _build_key(prefix: str, func: Callable, args: tuple, kwargs: dict) -> str:
26
+ """Build a deterministic cache key from function identity and arguments.
27
+
28
+ Format: ``{prefix}{module}.{qualname}:{repr(args)}:{repr(sorted(kwargs.items()))}``
29
+ """
30
+ arg_part = _repr.repr(args)
31
+ kw_part = _repr.repr(sorted(kwargs.items()))
32
+ return f"{prefix}{func.__module__}.{func.__qualname__}:{arg_part}:{kw_part}"
33
+
34
+
35
+ class _CachedDescriptor:
36
+ """Sync cached descriptor.
37
+
38
+ Returned by ``Cache.cached(...)`` and wraps a synchronous function.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ engine: "AdValEngine",
44
+ func: Callable,
45
+ regen_ms: float,
46
+ regen_usd: float,
47
+ change_interval_s: float,
48
+ cls: str,
49
+ size_bytes: Optional[int],
50
+ key_fn: Optional[Callable],
51
+ key_prefix: str,
52
+ ) -> None:
53
+ self._engine = engine
54
+ self._func = func
55
+ self._regen_ms = regen_ms
56
+ self._regen_usd = regen_usd
57
+ self._change_interval_s = change_interval_s
58
+ self._cls = cls
59
+ self._size_bytes = size_bytes
60
+ self._key_fn = key_fn
61
+ self._key_prefix = key_prefix
62
+ functools.update_wrapper(self, func)
63
+
64
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
65
+ if self._key_fn is not None:
66
+ key = self._key_prefix + self._key_fn(*args, **kwargs)
67
+ else:
68
+ key = _build_key(self._key_prefix, self._func, args, kwargs)
69
+
70
+ result = self._engine.get(key)
71
+ if result is not None:
72
+ value, _is_fresh, _freshness, _score = result
73
+ return value
74
+
75
+ value = self._func(*args, **kwargs)
76
+ self._engine.put(
77
+ key=key, data=value, cls=self._cls,
78
+ size_bytes=self._size_bytes,
79
+ regen_ms=self._regen_ms,
80
+ regen_usd=self._regen_usd,
81
+ change_interval_s=self._change_interval_s,
82
+ )
83
+ return value
84
+
85
+ def invalidate(self, *args: Any, **kwargs: Any) -> bool:
86
+ """Manually invalidate the cache entry for a specific set of arguments."""
87
+ if self._key_fn is not None:
88
+ key = self._key_prefix + self._key_fn(*args, **kwargs)
89
+ else:
90
+ key = _build_key(self._key_prefix, self._func, args, kwargs)
91
+ return self._engine.invalidate(key)
92
+
93
+ def invalidate_all(self) -> int:
94
+ """Invalidate all cache entries for this function."""
95
+ prefix = f"{self._key_prefix}{self._func.__module__}.{self._func.__qualname__}:"
96
+ return self._engine.invalidate_prefix(prefix)
97
+
98
+
99
+ class _AsyncCachedDescriptor:
100
+ """Async cached descriptor for AsyncCache."""
101
+
102
+ def __init__(
103
+ self,
104
+ engine: "AsyncAdValEngine",
105
+ func: Callable,
106
+ regen_ms: float,
107
+ regen_usd: float,
108
+ change_interval_s: float,
109
+ cls: str,
110
+ size_bytes: Optional[int],
111
+ key_fn: Optional[Callable],
112
+ key_prefix: str,
113
+ ) -> None:
114
+ self._engine = engine
115
+ self._func = func
116
+ self._regen_ms = regen_ms
117
+ self._regen_usd = regen_usd
118
+ self._change_interval_s = change_interval_s
119
+ self._cls = cls
120
+ self._size_bytes = size_bytes
121
+ self._key_fn = key_fn
122
+ self._key_prefix = key_prefix
123
+ functools.update_wrapper(self, func)
124
+
125
+ async def __call__(self, *args: Any, **kwargs: Any) -> Any:
126
+ if self._key_fn is not None:
127
+ key = self._key_prefix + self._key_fn(*args, **kwargs)
128
+ else:
129
+ key = _build_key(self._key_prefix, self._func, args, kwargs)
130
+
131
+ result = await self._engine.get(key)
132
+ if result is not None:
133
+ value, _is_fresh, _freshness, _score = result
134
+ return value
135
+
136
+ value = await self._func(*args, **kwargs)
137
+ await self._engine.put(
138
+ key=key, data=value, cls=self._cls,
139
+ size_bytes=self._size_bytes,
140
+ regen_ms=self._regen_ms,
141
+ regen_usd=self._regen_usd,
142
+ change_interval_s=self._change_interval_s,
143
+ )
144
+ return value
145
+
146
+ async def invalidate(self, *args: Any, **kwargs: Any) -> bool:
147
+ if self._key_fn is not None:
148
+ key = self._key_prefix + self._key_fn(*args, **kwargs)
149
+ else:
150
+ key = _build_key(self._key_prefix, self._func, args, kwargs)
151
+ return await self._engine.invalidate(key)
152
+
153
+ async def invalidate_all(self) -> int:
154
+ prefix = f"{self._key_prefix}{self._func.__module__}.{self._func.__qualname__}:"
155
+ return await self._engine.invalidate_prefix(prefix)
156
+
157
+
158
+ def make_cached_decorator(
159
+ engine: Any,
160
+ key_prefix: str = "",
161
+ is_async: bool = False,
162
+ ) -> Callable:
163
+ """Return a ``cached(...)`` decorator factory bound to an engine.
164
+
165
+ This is what ``Cache.cached`` and ``AsyncCache.cached`` are.
166
+ """
167
+
168
+ def cached(
169
+ regen_ms: float = 100.0,
170
+ regen_usd: float = 0.0,
171
+ change_interval_s: float = 0.0,
172
+ cls: str = "generic",
173
+ size_bytes: Optional[int] = None,
174
+ key: Optional[Callable] = None,
175
+ ) -> Callable:
176
+ """Decorate a function so its return value is cached by AdVal.
177
+
178
+ Args:
179
+ regen_ms: How long it takes to re-generate this value (milliseconds).
180
+ Used to compute regeneration pain. Higher = more protected from eviction.
181
+ regen_usd: Dollar cost to re-generate this value.
182
+ Used alongside regen_ms in the pain term.
183
+ change_interval_s: How often this object changes upstream (seconds).
184
+ 0 = immutable (always fresh). Small values (< 5 s) may trigger admission
185
+ control to decline caching — AdVal won't cache objects that will go stale
186
+ before they are likely to be accessed again.
187
+ cls: Object class label for telemetry grouping (e.g. "api_response",
188
+ "ml_result", "static_asset"). Purely informational.
189
+ size_bytes: Override the estimated size. If None, estimated via sys.getsizeof.
190
+ key: Optional callable that takes the same arguments as the decorated function
191
+ and returns a string cache key. Default: auto-built from function
192
+ name + repr(args).
193
+ """
194
+ def decorator(func: Callable) -> Any:
195
+ if is_async or asyncio.iscoroutinefunction(func):
196
+ return _AsyncCachedDescriptor(
197
+ engine=engine, func=func,
198
+ regen_ms=regen_ms, regen_usd=regen_usd,
199
+ change_interval_s=change_interval_s, cls=cls,
200
+ size_bytes=size_bytes, key_fn=key, key_prefix=key_prefix,
201
+ )
202
+ else:
203
+ return _CachedDescriptor(
204
+ engine=engine, func=func,
205
+ regen_ms=regen_ms, regen_usd=regen_usd,
206
+ change_interval_s=change_interval_s, cls=cls,
207
+ size_bytes=size_bytes, key_fn=key, key_prefix=key_prefix,
208
+ )
209
+ return decorator
210
+
211
+ return cached
advalcache/py.typed ADDED
File without changes
advalcache/server.py ADDED
@@ -0,0 +1,118 @@
1
+ """advalcache/server.py — Optional built-in observability server.
2
+
3
+ Requires the 'fastapi' extra: pip install advalcache[fastapi]
4
+
5
+ Exposes:
6
+ GET /stats - full telemetry snapshot
7
+ GET /keys - top N resident keys by score
8
+ GET /health - lightweight health check
9
+ POST /invalidate - {"key": "..."} or {"prefix": "..."}
10
+ POST /clear - flush all entries
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ from typing import TYPE_CHECKING, Any, Dict, Optional
17
+
18
+ try:
19
+ import uvicorn
20
+ from fastapi import FastAPI
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import JSONResponse
23
+ from pydantic import BaseModel
24
+ _HAS_FASTAPI = True
25
+ except ImportError:
26
+ _HAS_FASTAPI = False
27
+
28
+ if TYPE_CHECKING:
29
+ from advalcache._engine import AdValEngine
30
+
31
+
32
+ class _InvalidateRequest(BaseModel):
33
+ key: Optional[str] = None
34
+ prefix: Optional[str] = None
35
+
36
+
37
+ class ObsServer:
38
+ """Built-in FastAPI observability server for an AdValEngine.
39
+
40
+ Started automatically when ``CacheConfig.obs_port`` is set.
41
+ Runs on a daemon thread — does not block the main process.
42
+ """
43
+
44
+ def __init__(self, engine: "AdValEngine", port: int = 9999) -> None:
45
+ if not _HAS_FASTAPI:
46
+ raise ImportError(
47
+ "advalcache observability server requires 'fastapi' and 'uvicorn'. "
48
+ "Install with: pip install advalcache[fastapi]"
49
+ )
50
+ self._engine = engine
51
+ self._port = port
52
+ self._server: Optional[Any] = None
53
+ self._app = self._build_app()
54
+
55
+ def _build_app(self) -> "FastAPI":
56
+ engine = self._engine
57
+ app = FastAPI(
58
+ title="AdVal Cache — Observability",
59
+ description="Live telemetry and control for an AdVal in-memory cache instance.",
60
+ version="0.1.0",
61
+ )
62
+ app.add_middleware(
63
+ CORSMiddleware,
64
+ allow_origins=["*"],
65
+ allow_methods=["*"],
66
+ allow_headers=["*"],
67
+ )
68
+
69
+ @app.get("/health")
70
+ def health() -> Dict[str, Any]:
71
+ return {
72
+ "status": "healthy",
73
+ "resident_count": engine.count,
74
+ "resident_mb": engine.resident_mb,
75
+ "capacity_mb": engine.capacity_mb,
76
+ }
77
+
78
+ @app.get("/stats")
79
+ def stats() -> JSONResponse:
80
+ return JSONResponse(engine.snapshot())
81
+
82
+ @app.get("/keys")
83
+ def keys(limit: int = 25) -> JSONResponse:
84
+ snap = engine.snapshot()
85
+ top = snap.get("top_resident_keys", [])[:limit]
86
+ return JSONResponse({"keys": top, "total": snap.get("resident_count", 0)})
87
+
88
+ @app.post("/invalidate")
89
+ def invalidate(req: _InvalidateRequest) -> Dict[str, Any]:
90
+ if req.key:
91
+ removed = 1 if engine.invalidate(req.key) else 0
92
+ return {"removed": removed, "type": "key"}
93
+ elif req.prefix:
94
+ removed = engine.invalidate_prefix(req.prefix)
95
+ return {"removed": removed, "type": "prefix"}
96
+ return {"error": "provide 'key' or 'prefix'"}
97
+
98
+ @app.post("/clear")
99
+ def clear() -> Dict[str, Any]:
100
+ engine.clear()
101
+ return {"status": "cleared"}
102
+
103
+ return app
104
+
105
+ def run(self) -> None:
106
+ """Start uvicorn — intended to run on a daemon thread."""
107
+ config = uvicorn.Config(
108
+ self._app,
109
+ host="127.0.0.1",
110
+ port=self._port,
111
+ log_level="warning",
112
+ )
113
+ self._server = uvicorn.Server(config)
114
+ self._server.run()
115
+
116
+ def shutdown(self) -> None:
117
+ if self._server is not None:
118
+ self._server.should_exit = True
advalcache/stats.py ADDED
@@ -0,0 +1,84 @@
1
+ """advalcache/stats.py — CacheStats snapshot dataclass."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Dict, List
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class CacheStats:
11
+ """Frozen snapshot of cache telemetry at a point in time.
12
+
13
+ Create via ``cache.get_stats()``. All fields are immutable.
14
+ """
15
+
16
+ # Request counts
17
+ total_requests: int
18
+ hits: int
19
+ misses: int
20
+ fresh_hits: int
21
+ stale_hits: int
22
+ declines: int
23
+ proactive_refreshes: int
24
+
25
+ # Rates (percentages 0–100)
26
+ hit_rate_pct: float
27
+ freshness_pct: float
28
+
29
+ # Economics
30
+ usd_saved: float
31
+
32
+ # Memory
33
+ resident_mb: float
34
+ capacity_mb: float
35
+ resident_count: int
36
+
37
+ # Latency
38
+ avg_ram_latency_ms: float
39
+ avg_origin_latency_ms: float
40
+ speedup_factor: float
41
+
42
+ # Eviction breakdown
43
+ evictions: Dict[str, int]
44
+
45
+ # Recent events (list of dicts: time/op/key/details)
46
+ recent_events: List[Dict[str, Any]]
47
+
48
+ # Top resident keys by score
49
+ top_resident_keys: List[Dict[str, Any]]
50
+
51
+ @classmethod
52
+ def from_snapshot(cls, snap: Dict[str, Any]) -> "CacheStats":
53
+ """Build a CacheStats from the engine's snapshot dict."""
54
+ return cls(
55
+ total_requests=snap.get("total_requests", 0),
56
+ hits=snap.get("hits", 0),
57
+ misses=snap.get("misses", 0),
58
+ fresh_hits=snap.get("fresh_hits", 0),
59
+ stale_hits=snap.get("stale_hits", 0),
60
+ declines=snap.get("declines", 0),
61
+ proactive_refreshes=snap.get("proactive_refreshes", 0),
62
+ hit_rate_pct=snap.get("hit_rate_pct", 0.0),
63
+ freshness_pct=snap.get("freshness_pct", 0.0),
64
+ usd_saved=snap.get("usd_saved", 0.0),
65
+ resident_mb=snap.get("resident_mb", 0.0),
66
+ capacity_mb=snap.get("capacity_mb", 0.0),
67
+ resident_count=snap.get("resident_count", 0),
68
+ avg_ram_latency_ms=snap.get("avg_ram_latency_ms", 0.0),
69
+ avg_origin_latency_ms=snap.get("avg_origin_latency_ms", 0.0),
70
+ speedup_factor=snap.get("speedup_factor", 0.0),
71
+ evictions=snap.get("evictions", {}),
72
+ recent_events=snap.get("recent_events", []),
73
+ top_resident_keys=snap.get("top_resident_keys", []),
74
+ )
75
+
76
+ def summary(self) -> str:
77
+ """One-liner human-readable summary."""
78
+ return (
79
+ f"AdVal | hit={self.hit_rate_pct:.1f}% "
80
+ f"fresh={self.freshness_pct:.1f}% "
81
+ f"saved=${self.usd_saved:.4f} "
82
+ f"ram={self.resident_mb:.1f}/{self.capacity_mb:.1f}MB "
83
+ f"speedup={self.speedup_factor:.0f}x"
84
+ )
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.5
2
+ Name: advalcache
3
+ Version: 0.1.0
4
+ Summary: AdVal adaptive in-memory cache SDK — value-scored, freshness-aware, zero dependencies.
5
+ Project-URL: Homepage, https://github.com/mdtahseen7/VH26-PRESTORA
6
+ Project-URL: Repository, https://github.com/mdtahseen7/VH26-PRESTORA
7
+ Project-URL: Issues, https://github.com/mdtahseen7/VH26-PRESTORA/issues
8
+ Author-email: Md Tahseen <mdtahseen7378@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: adaptive,cache,caching,django,fastapi,in-memory,sdk
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Provides-Extra: all
24
+ Requires-Dist: django>=3.2; extra == 'all'
25
+ Requires-Dist: fastapi>=0.100.0; extra == 'all'
26
+ Requires-Dist: sqlalchemy>=1.4; extra == 'all'
27
+ Requires-Dist: uvicorn>=0.20.0; extra == 'all'
28
+ Provides-Extra: dev
29
+ Requires-Dist: fastapi>=0.100.0; extra == 'dev'
30
+ Requires-Dist: httpx>=0.24; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
32
+ Requires-Dist: pytest>=9.0; extra == 'dev'
33
+ Provides-Extra: django
34
+ Requires-Dist: django>=3.2; extra == 'django'
35
+ Provides-Extra: fastapi
36
+ Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
37
+ Requires-Dist: uvicorn>=0.20.0; extra == 'fastapi'
38
+ Provides-Extra: redis
39
+ Requires-Dist: redis>=4.0; extra == 'redis'
40
+ Provides-Extra: sqlalchemy
41
+ Requires-Dist: sqlalchemy>=1.4; extra == 'sqlalchemy'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # AdVal-Cache
45
+
46
+ **A cache that knows the difference between a $0.31 recommendation vector and a stock
47
+ ticker that is worthless two seconds after you store it.**
48
+
49
+ LRU asks *when did you last touch this*. LFU asks *how often*. Neither asks the only
50
+ question a bill cares about: **what does losing this actually cost me?** In a real
51
+ service that answer spans four orders of magnitude — a free 4 MB product image against
52
+ a ten-second, fifty-cent ML inference. AdVal-Cache computes a per-object **value
53
+ score** from access pattern, resource footprint, regeneration cost *and* data
54
+ volatility, then decides retain / evict / **refresh** / **decline** — and separately
55
+ decides, in dollars, when buying more cache capacity is worth the rent.
56
+
57
+ ## Quickstart
58
+
59
+ ```powershell
60
+ python -m venv venv; venv\Scripts\activate; pip install -r requirements.txt
61
+ python -m benchmarks.run_all --seed 42 # 80 cells in ~4 min -> results/report.md
62
+ streamlit run dashboard/app.py # playback dashboard
63
+ ```
64
+
65
+ Everything is a deterministic simulation: no network, no Redis, no Docker, CPU only.
66
+ Same seed ⇒ byte-identical traces, results and charts.
67
+
68
+ ## Results
69
+
70
+ 60 simulated minutes per scenario, seed 42. The problem statement's baselines are LRU, LFU
71
+ and GDS; exact GDSF ships alongside because it is stronger than the brief requires (D29).
72
+
73
+ **AdVal is cheaper *and* fresher than the best baseline in 8 of 8 cells.**
74
+
75
+ | scenario / profile | best baseline | baseline $ | **AdVal $** | saving | baseline fresh | **AdVal fresh** | pre-D31 base $ | pre-D31 AdVal $ |
76
+ |---|---|---|---|---|---|---|---|---|
77
+ | chaos / compute_heavy_rec | gds | 2854.48 | **2816.52** | **1.3%** | 66.2% | **78.5%** | 98.91 | 124.13 |
78
+ | chaos / read_heavy_api | gds | 1086.05 | **986.37** | **9.2%** | 64.2% | **74.2%** | 98.91 | 117.41 |
79
+ | gradual_shift / compute_heavy_rec | gds | 3034.96 | **2989.81** | **1.5%** | 67.1% | **80.2%** | 98.91 | 124.51 |
80
+ | gradual_shift / read_heavy_api | gds | 1046.89 | **923.08** | **11.8%** | 64.7% | **77.7%** | 98.91 | 123.12 |
81
+ | steady / compute_heavy_rec | gds | 4275.14 | **4235.45** | **0.9%** | 59.7% | **73.1%** | 98.91 | 122.15 |
82
+ | steady / read_heavy_api | gdsf_exact | 1282.12 | **1142.86** | **10.9%** | 57.9% | **70.8%** | 100.01 | 120.88 |
83
+ | viral_spike / compute_heavy_rec | gds | 4850.61 | **4802.40** | **1.0%** | 60.2% | **72.6%** | 98.91 | 124.59 |
84
+ | viral_spike / read_heavy_api | gds | 1877.68 | **1755.22** | **6.5%** | 47.1% | **56.0%** | 98.91 | 120.20 |
85
+
86
+ ### Read the last two columns before you believe the first ones
87
+
88
+ The cost model used to bill compute and memory and **nothing at all for serving a customer
89
+ stale data**. Nobody decided that; it is what you get by leaving wrong answers unpriced,
90
+ and it meant a cache-freshness system was scored on everything except the thing it does —
91
+ `gds` won on cost by serving 75,526 wrong answers an hour for free.
92
+
93
+ D31 fixes it on a principle rather than a knob: **a stale hit is not a hit, it is a miss
94
+ you failed to notice**, so it is charged the `regen_usd` of the very object it displaced.
95
+ The price is read per object out of the catalog, billed by the simulator so every policy
96
+ pays the same for the same event, and measured at $0.0077–$0.186 per stale serve. That is
97
+ 7–170x the ~0.11 cent break-even, which is why the ranking flips.
98
+
99
+ **On the old model `gds` is still cheaper** — that is the `pre-D31` pair above, kept in
100
+ `summary.csv` as `total_usd_compute_only` in every run, and
101
+ `python -m benchmarks.run_all --stale-mult 0` reproduces it exactly (a test asserts it).
102
+ The honest caveat: at the shipped price the staleness bill is ~10x compute plus memory, so
103
+ the ranking now rests mostly on this one priced assumption. The full flat-price sweep,
104
+ including where AdVal loses, is in `results/dominance.md`.
105
+
106
+ Savings are 6.5–11.8% on `read_heavy_api` and only 0.9–1.5% on `compute_heavy_rec`, where
107
+ slow-changing `ml_result` objects dominate the staleness bill for every arm alike.
108
+
109
+ ### The four answers that assume nothing
110
+
111
+ Separately from the cost model, `results/dominance.md`:
112
+
113
+ 1. **Nothing in the study is both cheaper and fresher than AdVal** — 16/16 arm-cells, on
114
+ either cost model.
115
+ 2. **Capacity cannot buy freshness.** `gds` serves 75,526 stale responses at 100 MB and
116
+ 75,526 at 500 MB while its hit rate climbs 81.6% → 93.9%. AdVal at 100 MB is cheaper
117
+ *and* 12.2 pp fresher than `gds` at 500 MB.
118
+ 3. **The flat-price sweep**: above ~0.11 cents per stale response AdVal is cheapest in all
119
+ 8 cells, to 4.6–45 cents where LRU takes over. Both bounds published.
120
+ 4. **At 50–60 MB, where memory is actually scarce**, AdVal is cheaper *and* fresher at
121
+ equal capacity even with staleness free.
122
+
123
+ Against LRU and LFU, AdVal is 92–99% cheaper on either model. It still loses on tail
124
+ latency — 158 ms p95 against 55 ms — because refreshing on read is not free. Full tables,
125
+ timelines, the ablation ladder and an auto-generated "where this result is weakest"
126
+ section: **`results/report.md`**.
127
+
128
+ ## The score
129
+
130
+ ```
131
+ score(i) = hotness(i) × pain(i) × freshness(i) / size_units(i)
132
+ ```
133
+
134
+ - **hotness** — LRFU decayed counter `c ← 1 + c·e^(−λΔt)`, λ = 0.001 (half-life ≈ 693 s);
135
+ recency at short lags, frequency at long ones, one number.
136
+ - **pain** — regeneration latency and regeneration dollars, each normalised against the
137
+ catalog's p99 so neither drowns the other. ~200× spread across object classes.
138
+ - **freshness** — `exp(−(now − last_sync) / change_interval)`, which for a Poisson
139
+ mutation process is exactly *P(the cached copy is still valid)*. Multiplying a value
140
+ by a probability gives an expected value, which is the right thing to rank by.
141
+ Exactly `1.0` for immutable objects and anything slower than 1800 s.
142
+ - **size_units** — `log1p(bytes / 1 KB)`, so 5 MB is penalised ~12× rather than ~5000×.
143
+ Large objects must justify themselves; they are not banned.
144
+
145
+ Two runtime loops keep it from being a set of hardcoded constants:
146
+
147
+ - **The cache A/B tests its own brain.** Every 5000 requests it shadow-replays the
148
+ window it just served through five candidate weight vectors, seeded from a snapshot
149
+ of its real state, and adopts whichever delivered the most value. `steady` picks
150
+ `freq_heavy`; `chaos` picks `fresh_heavy`. Nobody told it which signal matters.
151
+ - **Capacity is a dollar decision that checks itself.** A shadow zone measures the
152
+ marginal dollars the next slice of memory would have saved; the controller acts,
153
+ waits five windows, and reverts the change if realised spend per request did not fall
154
+ by more than the rent it took on. From a badly-sized 30 MB cache it cuts cost 72–84%.
155
+
156
+ `ARCHITECTURE.md` has the decision-engine diagram and the full justification.
157
+
158
+ ## Repo layout
159
+
160
+ ```
161
+ models.py THE CONTRACT: Request, CatalogEntry, object classes, ops, constants
162
+ proxy/ FastAPI reverse proxy sidecar · live cache engine · traffic generator
163
+ database/ Neon PostgreSQL client · schema · real-world dataset ingestion pipeline
164
+ cache/ base (interface, sampling, event sink) · lru · lfu · gdsf · adaptive · controller
165
+ generator/ catalog · popularity modes · scenario engine · trace writer · validator
166
+ sim/ simulator (replay + window aggregation) · metrics (the only cost model)
167
+ benchmarks/ run_all (the 80-cell matrix) · report (tables, charts, honest caveats)
168
+ dashboard/app.py Streamlit control center: batch replay scrubber & live dynamic proxy mode
169
+ web_adval/ React/Vite standalone web control UI mounted on proxy port 8000
170
+ traces/ trace samples & Postgres query trace generator
171
+ tests/ unit & integration tests: core, live cache, and proxy API
172
+ results/ event logs, window CSVs, summary.csv, report.md, charts/
173
+ ```
174
+
175
+ ## Verify it yourself
176
+
177
+ ```powershell
178
+ python -m generator.validate --minutes 60 --seed 42 # 9 workload checks, ~6 s
179
+ python -m pytest -q # 63 tests, ~8 s
180
+ python -m benchmarks.run_all --seed 42 # 80 cells, ~4 min
181
+ ```
182
+
183
+ `generator/validate.py` is the workload referee: it asserts the arrival-rate curve
184
+ matches the scenario spec within 4σ on all four scenarios, that the top-10 popular keys
185
+ genuinely rotate during `gradual_shift` (0 shared keys), that the reuse working set is
186
+ **4.05× cache capacity** (a cache that never fills has no story), that `static_asset` is
187
+ 99.4% of bytes but only 30.1% of requests, and that seed 42 reproduces byte-identical
188
+ files. A cache benchmark is only as honest as its workload.
189
+
190
+ ## Reading the docs
191
+
192
+ | file | what is in it |
193
+ |---|---|
194
+ | `ARCHITECTURE.md` | decision-engine diagram, every term of the score justified, the two adaptive loops |
195
+ | `results/report.md` | generated results: headline tables, charts, ablation, capacity fairness, weaknesses |
196
+ | `DECISIONS.md` | **22 numbered assumptions and corrections**, including two places the problem statement's own numbers disagree, three bugs that only a benchmark could have caught, and one metric that was measuring the wrong thing |
197
+ | `DEMO.md` | the scripted 5-minute walkthrough, with fallbacks |
198
+ | `FORMATS.md` | every on-disk schema: catalog, trace, change stream, event log, window CSV |
199
+ | `PROGRESS.md` | phase-by-phase build log with the ACCEPT evidence for each |
@@ -0,0 +1,20 @@
1
+ advalcache/__init__.py,sha256=DENMhpuJRLchnJwYE2nfRj_9yt6E7FfZSrjoedoa0Rk,10515
2
+ advalcache/__main__.py,sha256=kB4xw-ShLl9TlpdY_Khx-hg5c3K8dzX6YmGLZ9d-4lk,1861
3
+ advalcache/_engine.py,sha256=lFKEHjOsByLXsvZ_O1eANWqJvGkePu-ITG9SJbjU6k4,19754
4
+ advalcache/_eviction.py,sha256=DqO6hFyLVhms8p7Z38Eq6t3eEvPyY1PVnzpL0lBlNFc,2914
5
+ advalcache/_refresh.py,sha256=oa4E338jrazZm8Z4Ev1P9_CboQN_BLQbVIflPmXvHic,3139
6
+ advalcache/_scoring.py,sha256=OBP_1p1KQNA760cKUC9F90zE5KbWPdEdifOUhgHHSAU,5274
7
+ advalcache/config.py,sha256=3nNLecT2iknSCoj6Eg4d3k8qpQP7V5J8PFSLmFbzWMw,3941
8
+ advalcache/decorators.py,sha256=duARsgw4HTu-vwAVZ5_TiJNUbIpp8najygbioAPI03M,7884
9
+ advalcache/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ advalcache/server.py,sha256=rF0z6156icxDWAziEcjrdO81EW1jd5e9iWSzEBjhcXs,3878
11
+ advalcache/stats.py,sha256=B0Zx3mMH1s3OAaLUYAyy6Xf84RcTj3zepWWzqKfiR4c,2739
12
+ advalcache/adapters/__init__.py,sha256=PEWpmQVWXTIvNj1h6u5bo6l07Lg6SagoN6owQ7uoSrE,77
13
+ advalcache/adapters/django.py,sha256=kcxxenJ7NAyrrKQMfo9OBioh2SbbvuVzqUbOfF8MCJQ,5688
14
+ advalcache/adapters/fastapi.py,sha256=JnCgJezn0-CF51Xn_efu8EgnW5nyJomM_RxEooqQ-0g,2713
15
+ advalcache/adapters/sqlalchemy.py,sha256=hltXd5jFb1pKS5D5kQXMu1UNUTvVMSEdrRk2XJ76tek,2990
16
+ advalcache-0.1.0.dist-info/METADATA,sha256=Ns4Vfn71BUYYBR_L2bdHX8Piz5_a3ghSac0op7h2fJE,11179
17
+ advalcache-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
18
+ advalcache-0.1.0.dist-info/entry_points.txt,sha256=szozXPcpLUu7v52lr6XsxZtglUYM_RyZ45e7abQtCbo,56
19
+ advalcache-0.1.0.dist-info/licenses/LICENSE,sha256=_T2oruKd3b683cdBOXvaRA_GgfumOqLua0dxXgEKqkg,1084
20
+ advalcache-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ advalcache = advalcache.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Manaal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.