Commit fe4f9ee
feat(policy): decay P_alive from idle time near cache TTL (headroomlabs-ai#856 P3b) (headroomlabs-ai#1028)
## Description
headroomlabs-ai#856 P3b (umbrella headroomlabs-ai#904), the idle-timer-compaction increment after P2
(headroomlabs-ai#905), P2b (headroomlabs-ai#944), and P3a (headroomlabs-ai#1015), all merged.
Anthropic prompt-cache entries live in a ~5-minute TTL tier (the basis
for the 1.25× write multiplier). As a session goes idle the cached
suffix approaches lapse, so **P_alive** — the probability the cache
still survives to the next turn — decays toward 0. When P_alive → 0 the
net-cost penalty term `P_alive·(w−r)·(S+ΔT)` vanishes and a deep edit
near lapse is free to make: the suffix is about to be rebuilt cold
regardless. P2/P3a fed the break-even gate a **static**
`HEADROOM_NET_COST_P_ALIVE` constant; this derives P_alive from an idle
signal when one is available.
Flag-gated under `HEADROOM_NET_COST_POLICY` (the same flag as
P2/P2b/P3a), default **off**.
## Type of Change
- [x] New feature (non-breaking change which adds functionality)
- [ ] Bug fix
- [ ] Breaking change
- [ ] Documentation
## Changes Made
- `ContentRouter.apply`: reads an optional `idle_seconds` kwarg and
derives `P_alive = max(0, 1 − idle_s / ttl)` **once per request** (idle
is a per-request property, like `frozen_message_count`), passing it to
the gate as `p_alive_override`. Absent/malformed `idle_seconds` → `None`
→ the P2 env-constant path is preserved exactly.
- `ContentRouter._net_cost_allows`: new `p_alive_override` param. When
set it replaces the `HEADROOM_NET_COST_P_ALIVE` constant (clamped to
[0,1]); otherwise unchanged. An admit made under a decayed (`< 1.0`)
idle P_alive emits the `router:netcost_idle_compaction` marker and the
`netcost_idle_admitted` counter (independent of the P3a batch marker;
both may apply).
- Cache TTL: module default 300s (Anthropic tier), overridable via
`HEADROOM_NET_COST_CACHE_TTL_SECONDS`, with malformed/non-positive
guards. Explicitly **distinct** from
`PrefixFreezeConfig.session_ttl_seconds` (tracker cleanup, 600s).
- `PrefixCacheTracker.seconds_since_activity()`: exposes the idle signal
for the proxy handlers to plumb (see Additional Notes).
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] New tests added for new functionality
### Test Output
```text
$ pytest tests/test_netcost_gate.py -q
25 passed in 2.03s
$ pytest tests/ -k "content_router or netcost or router or prefix_tracker or prefix" -q
245 passed, 8 skipped, 6252 deselected, 1 warning in 24.47s
$ ruff check headroom/transforms/content_router.py headroom/cache/prefix_tracker.py tests/test_netcost_gate.py
All checks passed!
$ ruff format --check headroom/transforms/content_router.py headroom/cache/prefix_tracker.py tests/test_netcost_gate.py
3 files already formatted
$ mypy headroom/transforms/content_router.py headroom/cache/prefix_tracker.py
Success: no issues found in 2 source files
```
## Real Behavior Proof
- Environment: local macOS, repo .venv, Python 3.11.9, gpt-4o tokenizer
fixture
- Exact command / steps: drive `ContentRouter.apply()` on the P2
"blocked" baseline (a modest tool-dump shave, ΔT≈5K, under a ~120K-token
cached suffix — rejected at the default P_alive=1.0), varying only
`idle_seconds`.
- Observed result: `idle_seconds=295` (TTL 300) → P_alive≈0.017, penalty
collapses, the edit is admitted and `router:netcost_idle_compaction` is
emitted; `idle_seconds=0` → P_alive=1.0, byte-identical to the constant
baseline (still blocked, `netcost:skip:` emitted, no idle marker);
absent/malformed `idle_seconds` → env-constant path (blocked);
`HEADROOM_NET_COST_CACHE_TTL_SECONDS=60` with `idle_seconds=59` → unlock
(custom TTL controls the decay).
- Not tested: live proxy traffic — deferred to the default-on milestone
per headroomlabs-ai#904 (ships default-off to gather telemetry first).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Additional Notes
**Proxy wiring is a deliberate follow-up**, mirroring how P2 shipped
P_alive as an unplumbed constant and gathered telemetry before
default-on. The gate already honors `idle_seconds` via kwarg and
`PrefixCacheTracker.seconds_since_activity()` exposes the value; the
remaining step is for the provider handlers (`handlers/anthropic.py`,
`handlers/openai.py`) to pass it alongside the existing
`frozen_message_count` kwarg (`pipeline.apply` already forwards
`**kwargs` to `transform.apply`, so no pipeline change is needed). One
wiring caveat is documented on `seconds_since_activity()`:
`SessionTrackerStore.get_or_create` refreshes `_last_activity` on
access, so the handler must read idle before fetching the tracker for
the current request. Kept out of this PR for reviewability and because
it touches ~10 call sites across both providers.
---------
Co-authored-by: JD Davis <mxjerrett@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>1 parent 8894ee0 commit fe4f9ee
3 files changed
Lines changed: 170 additions & 8 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
218 | 236 | | |
219 | 237 | | |
220 | 238 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
216 | 216 | | |
217 | 217 | | |
218 | 218 | | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
219 | 258 | | |
220 | 259 | | |
221 | 260 | | |
| |||
2029 | 2068 | | |
2030 | 2069 | | |
2031 | 2070 | | |
| 2071 | + | |
2032 | 2072 | | |
2033 | 2073 | | |
2034 | 2074 | | |
| |||
2057 | 2097 | | |
2058 | 2098 | | |
2059 | 2099 | | |
| 2100 | + | |
| 2101 | + | |
| 2102 | + | |
| 2103 | + | |
| 2104 | + | |
| 2105 | + | |
| 2106 | + | |
| 2107 | + | |
| 2108 | + | |
| 2109 | + | |
| 2110 | + | |
| 2111 | + | |
2060 | 2112 | | |
2061 | 2113 | | |
2062 | 2114 | | |
| |||
2086 | 2138 | | |
2087 | 2139 | | |
2088 | 2140 | | |
2089 | | - | |
2090 | | - | |
2091 | | - | |
2092 | | - | |
2093 | | - | |
2094 | | - | |
2095 | | - | |
| 2141 | + | |
| 2142 | + | |
| 2143 | + | |
| 2144 | + | |
| 2145 | + | |
| 2146 | + | |
| 2147 | + | |
| 2148 | + | |
| 2149 | + | |
| 2150 | + | |
| 2151 | + | |
| 2152 | + | |
| 2153 | + | |
| 2154 | + | |
| 2155 | + | |
2096 | 2156 | | |
2097 | 2157 | | |
2098 | 2158 | | |
2099 | 2159 | | |
2100 | | - | |
| 2160 | + | |
2101 | 2161 | | |
2102 | 2162 | | |
2103 | 2163 | | |
2104 | 2164 | | |
2105 | 2165 | | |
| 2166 | + | |
2106 | 2167 | | |
2107 | 2168 | | |
2108 | 2169 | | |
2109 | 2170 | | |
2110 | 2171 | | |
2111 | 2172 | | |
2112 | 2173 | | |
| 2174 | + | |
| 2175 | + | |
| 2176 | + | |
| 2177 | + | |
| 2178 | + | |
| 2179 | + | |
| 2180 | + | |
2113 | 2181 | | |
2114 | 2182 | | |
2115 | 2183 | | |
| |||
2313 | 2381 | | |
2314 | 2382 | | |
2315 | 2383 | | |
| 2384 | + | |
| 2385 | + | |
| 2386 | + | |
| 2387 | + | |
| 2388 | + | |
| 2389 | + | |
2316 | 2390 | | |
2317 | 2391 | | |
2318 | 2392 | | |
2319 | 2393 | | |
2320 | 2394 | | |
2321 | 2395 | | |
| 2396 | + | |
| 2397 | + | |
| 2398 | + | |
| 2399 | + | |
| 2400 | + | |
| 2401 | + | |
| 2402 | + | |
| 2403 | + | |
| 2404 | + | |
2322 | 2405 | | |
2323 | 2406 | | |
2324 | 2407 | | |
| |||
2512 | 2595 | | |
2513 | 2596 | | |
2514 | 2597 | | |
| 2598 | + | |
2515 | 2599 | | |
2516 | 2600 | | |
2517 | 2601 | | |
| |||
2594 | 2678 | | |
2595 | 2679 | | |
2596 | 2680 | | |
| 2681 | + | |
2597 | 2682 | | |
2598 | 2683 | | |
2599 | 2684 | | |
| |||
2651 | 2736 | | |
2652 | 2737 | | |
2653 | 2738 | | |
| 2739 | + | |
| 2740 | + | |
2654 | 2741 | | |
2655 | 2742 | | |
2656 | 2743 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
354 | 354 | | |
355 | 355 | | |
356 | 356 | | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
0 commit comments