Skip to content

Commit fe4f9ee

Browse files
MrAshRhodesJerrettDavisCopilot
authored
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

File tree

headroom/cache/prefix_tracker.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,24 @@ def is_expired(self) -> bool:
215215
"""Check if this tracker has been idle beyond TTL."""
216216
return (time.time() - self._last_activity) > self.config.session_ttl_seconds
217217

218+
def seconds_since_activity(self) -> float:
219+
"""Wall-clock seconds since this tracker last saw activity.
220+
221+
#856 P3b feeds this to the net-cost gate as an idle signal: as it
222+
approaches the provider's prompt-cache TTL (~300s for Anthropic),
223+
P_alive decays toward 0 and deep edits near cache lapse become free.
224+
Distinct from :attr:`is_expired`, which uses the much longer
225+
session-tracker *cleanup* TTL (``session_ttl_seconds``), not the cache
226+
TTL.
227+
228+
Wiring caveat: ``SessionTrackerStore.get_or_create`` refreshes
229+
``_last_activity`` on access, so a caller that wants the idle gap
230+
since the *previous turn's response* must read this before fetching
231+
the tracker for the current request (or the store must capture it at
232+
fetch time). ``update_from_response`` is the per-turn activity stamp.
233+
"""
234+
return max(0.0, time.time() - self._last_activity)
235+
218236
@property
219237
def stats(self) -> FreezeStats:
220238
"""Return stats for dashboard/metrics."""

headroom/transforms/content_router.py

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,45 @@ def _create_content_signature(
216216
return None
217217

218218

219+
# #856 P3b: Anthropic prompt-cache entries live in a 5-minute TTL tier (the
220+
# basis for the 1.25x write multiplier). As a session goes idle the cached
221+
# suffix approaches lapse, so P_alive — the probability the cache survives to
222+
# the next turn — decays toward 0. When P_alive hits 0 the net-cost penalty
223+
# term vanishes and a deep edit near lapse is free to make (the suffix is
224+
# about to be rebuilt cold anyway). This is the cache TTL, NOT the
225+
# session-tracker cleanup TTL (``PrefixFreezeConfig.session_ttl_seconds``).
226+
_NET_COST_CACHE_TTL_SECONDS = 300.0
227+
228+
229+
def _net_cost_cache_ttl_seconds() -> float:
230+
"""Provider cache TTL (seconds) used to decay P_alive from idle time.
231+
232+
Defaults to Anthropic's 5-minute tier; overridable via
233+
``HEADROOM_NET_COST_CACHE_TTL_SECONDS`` for other providers/tiers. A
234+
malformed or non-positive value falls back to the default with a warning
235+
rather than producing a divide-by-zero or negative TTL (same posture as
236+
the other ``HEADROOM_NET_COST_*`` env guards).
237+
"""
238+
raw = os.environ.get("HEADROOM_NET_COST_CACHE_TTL_SECONDS", "")
239+
if not raw:
240+
return _NET_COST_CACHE_TTL_SECONDS
241+
try:
242+
ttl = float(raw)
243+
except ValueError:
244+
logger.warning(
245+
"HEADROOM_NET_COST_CACHE_TTL_SECONDS malformed; using default %s",
246+
_NET_COST_CACHE_TTL_SECONDS,
247+
)
248+
return _NET_COST_CACHE_TTL_SECONDS
249+
if not math.isfinite(ttl) or ttl <= 0.0:
250+
logger.warning(
251+
"HEADROOM_NET_COST_CACHE_TTL_SECONDS invalid; using default %s",
252+
_NET_COST_CACHE_TTL_SECONDS,
253+
)
254+
return _NET_COST_CACHE_TTL_SECONDS
255+
return ttl
256+
257+
219258
def _gain_bucket(gain: float) -> str:
220259
"""Quantize a net-cost gain into a coarse magnitude band for markers.
221260
@@ -2029,6 +2068,7 @@ def _net_cost_allows(
20292068
route_counts: dict[str, int],
20302069
transforms_applied: list[str],
20312070
batch_state: dict[str, int | None] | None = None,
2071+
p_alive_override: float | None = None,
20322072
) -> bool:
20332073
"""Break-even gate for one candidate mutation (#856 P2, flag-gated).
20342074
@@ -2057,6 +2097,18 @@ def _net_cost_allows(
20572097
mutated shallower slot. Each batch admission emits the
20582098
``router:netcost_batch_admit`` marker and the ``netcost_batch_admitted``
20592099
counter for telemetry.
2100+
2101+
#856 P3b (idle-timer compaction): ``p_alive_override``, when supplied
2102+
by the caller, replaces the static ``HEADROOM_NET_COST_P_ALIVE``
2103+
constant. It is derived in ``apply`` from how long the session has
2104+
been idle relative to the provider cache TTL
2105+
(``max(0, 1 − idle_s / ttl)``). As the cached suffix nears lapse
2106+
P_alive → 0, the ``P_alive·(w−r)·(S+ΔT)`` penalty vanishes, and edits
2107+
that would lose to a warm suffix become free — the suffix is about to
2108+
be rebuilt cold regardless. ``None`` preserves the P2 env-constant
2109+
behaviour. An admit made under a decayed (``< 1.0``) idle P_alive emits
2110+
the ``router:netcost_idle_compaction`` marker and the
2111+
``netcost_idle_admitted`` counter.
20602112
"""
20612113
delta_t = max(0, original_tokens - compressed_tokens)
20622114
# Batch reclaim: if a shallower slot was already admitted, its
@@ -2086,30 +2138,46 @@ def _net_cost_allows(
20862138
reads = _reads
20872139
except ValueError:
20882140
logger.warning("HEADROOM_NET_COST_EXPECTED_READS malformed; using 10")
2089-
try:
2090-
_p_alive = float(os.environ.get("HEADROOM_NET_COST_P_ALIVE", "") or 1.0)
2091-
if not math.isfinite(_p_alive):
2092-
raise ValueError("non-finite")
2093-
p_alive = _p_alive
2094-
except ValueError:
2095-
logger.warning("HEADROOM_NET_COST_P_ALIVE malformed; using 1.0")
2141+
# #856 P3b: an idle-derived override takes precedence over the static
2142+
# env constant. ``net_mutation_gain`` clamps p_alive to [0, 1]
2143+
# internally, but clamp here too so the value logged/branched on below
2144+
# matches what the formula uses.
2145+
idle_derived = p_alive_override is not None
2146+
if p_alive_override is not None:
2147+
p_alive = min(max(p_alive_override, 0.0), 1.0)
2148+
else:
2149+
try:
2150+
_p_alive = float(os.environ.get("HEADROOM_NET_COST_P_ALIVE", "") or 1.0)
2151+
if not math.isfinite(_p_alive):
2152+
raise ValueError("non-finite")
2153+
p_alive = _p_alive
2154+
except ValueError:
2155+
logger.warning("HEADROOM_NET_COST_P_ALIVE malformed; using 1.0")
20962156
gain = float(policy.net_mutation_gain(delta_t, suffix, reads, p_alive))
20972157
allowed = gain > 0.0
20982158
logger.info(
20992159
"NetCostPolicy slot=%d delta_t=%d suffix=%d reads=%.1f p_alive=%.2f "
2100-
"gain=%.0f batch_reclaim=%s -> %s",
2160+
"idle_derived=%s gain=%.0f batch_reclaim=%s -> %s",
21012161
slot_idx,
21022162
delta_t,
21032163
suffix,
21042164
reads,
21052165
p_alive,
2166+
idle_derived,
21062167
gain,
21072168
batch_reclaim,
21082169
"mutate" if allowed else "skip",
21092170
)
21102171
if allowed:
21112172
route_counts.setdefault("netcost_allowed", 0)
21122173
route_counts["netcost_allowed"] += 1
2174+
if idle_derived and p_alive < 1.0:
2175+
# Admitted under an idle-decayed P_alive: the cached suffix is
2176+
# near TTL lapse, so its invalidation penalty is discounted.
2177+
# Independent of batch reclaim — both markers may apply.
2178+
route_counts.setdefault("netcost_idle_admitted", 0)
2179+
route_counts["netcost_idle_admitted"] += 1
2180+
transforms_applied.append("router:netcost_idle_compaction")
21132181
if batch_reclaim:
21142182
# Rode a shallower edit's cache-bust for free — telemetry only;
21152183
# the floor is unchanged (this slot is deeper than the floor).
@@ -2313,12 +2381,27 @@ def apply(
23132381
# the shallowest slot admitted as a net-positive mutation; once set,
23142382
# deeper candidates charge S=0 (their cache-bust is already paid).
23152383
netcost_batch_state: dict[str, int | None] = {"floor": None}
2384+
# #856 P3b (idle-timer compaction): if the caller supplies how long the
2385+
# session has been idle, decay P_alive from it once per request and
2386+
# pass it to the gate. Absent/malformed → None → the gate keeps the P2
2387+
# env-constant behaviour. Derived once here (not per slot) — idle is a
2388+
# per-request property, like frozen_message_count.
2389+
netcost_p_alive_override: float | None = None
23162390
if netcost_enabled:
23172391
netcost_suffix_tokens = [0] * (num_messages + 1)
23182392
for j in range(num_messages - 1, -1, -1):
23192393
netcost_suffix_tokens[j] = netcost_suffix_tokens[j + 1] + _netcost_message_tokens(
23202394
messages[j], tokenizer
23212395
)
2396+
idle_seconds = kwargs.get("idle_seconds")
2397+
if idle_seconds is not None:
2398+
try:
2399+
idle_f = float(idle_seconds)
2400+
except (TypeError, ValueError):
2401+
idle_f = None
2402+
if idle_f is not None and math.isfinite(idle_f) and idle_f >= 0.0:
2403+
ttl = _net_cost_cache_ttl_seconds()
2404+
netcost_p_alive_override = max(0.0, 1.0 - idle_f / ttl)
23222405

23232406
# Tasks: list of (slot_index, content, context, bias, content_key)
23242407
_PendingTask = tuple[int, str, str, float, int]
@@ -2512,6 +2595,7 @@ def apply(
25122595
route_counts=route_counts,
25132596
transforms_applied=transforms_applied,
25142597
batch_state=netcost_batch_state,
2598+
p_alive_override=netcost_p_alive_override,
25152599
):
25162600
# Net-cost gate: mutation would cost more in cache
25172601
# invalidation than it saves — leave untouched.
@@ -2594,6 +2678,7 @@ def apply(
25942678
route_counts=route_counts,
25952679
transforms_applied=transforms_applied,
25962680
batch_state=netcost_batch_state,
2681+
p_alive_override=netcost_p_alive_override,
25972682
):
25982683
result_slots[slot_idx] = message
25992684
continue
@@ -2651,6 +2736,8 @@ def apply(
26512736
parts.append(f"{route_counts['cache_miss']} cache misses")
26522737
if route_counts.get("netcost_batch_admitted"):
26532738
parts.append(f"{route_counts['netcost_batch_admitted']} netcost batch-admitted")
2739+
if route_counts.get("netcost_idle_admitted"):
2740+
parts.append(f"{route_counts['netcost_idle_admitted']} netcost idle-admitted")
26542741
cs = self._cache.stats
26552742
if cs["cache_size"] > 0 or cs["cache_skip_size"] > 0:
26562743
parts.append(

tests/test_netcost_gate.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,60 @@ def test_frozen_unlock_and_batch_combine(self, router, tokenizer, monkeypatch):
354354
batch = [t for t in result.transforms_applied if t == "router:netcost_batch_admit"]
355355
assert len(unlocks) == 2 # both frozen slots opened
356356
assert len(batch) == 1 # only the deeper one rode free -- no double-count
357+
358+
359+
class TestNetCostIdleCompaction:
360+
"""#856 P3b: derive P_alive from idle time. As the session goes idle the
361+
cached suffix nears TTL lapse, P_alive -> 0, the net-cost penalty term
362+
vanishes, and edits that lose to a warm suffix become free.
363+
364+
Baseline shape (mirrors TestNetCostGate.test_flag_on_blocks...): a modest
365+
tool-dump shave under a huge cached suffix is BLOCKED at the default
366+
P_alive=1.0. These tests vary only the idle signal.
367+
"""
368+
369+
def test_idle_near_ttl_unlocks_blocked_edit(self, router, tokenizer, monkeypatch):
370+
# idle ~= cache TTL (default 300s) -> P_alive ~= 0 -> penalty ~= 0 ->
371+
# the otherwise-blocked deep edit is admitted and marked.
372+
monkeypatch.setenv("HEADROOM_NET_COST_POLICY", "1")
373+
messages = _messages(_tool_json(300), suffix_filler_words=40000)
374+
result = router.apply([dict(m) for m in messages], tokenizer, idle_seconds=295.0)
375+
assert _tool_slot_compressed(result, messages)
376+
assert "router:netcost_idle_compaction" in result.transforms_applied
377+
assert not any(t.startswith("netcost:skip:") for t in result.transforms_applied)
378+
379+
def test_idle_zero_matches_constant_baseline(self, router, tokenizer, monkeypatch):
380+
# idle=0 -> P_alive=1.0, identical to the env-constant default: the
381+
# edit stays blocked and no idle marker is emitted.
382+
monkeypatch.setenv("HEADROOM_NET_COST_POLICY", "1")
383+
messages = _messages(_tool_json(300), suffix_filler_words=40000)
384+
result = router.apply([dict(m) for m in messages], tokenizer, idle_seconds=0.0)
385+
assert not _tool_slot_compressed(result, messages)
386+
assert "router:netcost_idle_compaction" not in result.transforms_applied
387+
assert any(t.startswith("netcost:skip:") for t in result.transforms_applied)
388+
389+
def test_idle_absent_uses_env_constant(self, router, tokenizer, monkeypatch):
390+
# No idle_seconds kwarg -> override is None -> P2 env-constant path.
391+
monkeypatch.setenv("HEADROOM_NET_COST_POLICY", "1")
392+
messages = _messages(_tool_json(300), suffix_filler_words=40000)
393+
result = router.apply([dict(m) for m in messages], tokenizer)
394+
assert not _tool_slot_compressed(result, messages)
395+
assert "router:netcost_idle_compaction" not in result.transforms_applied
396+
397+
def test_malformed_idle_falls_back_to_constant(self, router, tokenizer, monkeypatch):
398+
# Non-numeric idle_seconds is ignored (override stays None), so the
399+
# gate keeps the constant behaviour rather than crashing the request.
400+
monkeypatch.setenv("HEADROOM_NET_COST_POLICY", "1")
401+
messages = _messages(_tool_json(300), suffix_filler_words=40000)
402+
result = router.apply([dict(m) for m in messages], tokenizer, idle_seconds="soon")
403+
assert not _tool_slot_compressed(result, messages)
404+
assert "router:netcost_idle_compaction" not in result.transforms_applied
405+
406+
def test_custom_ttl_env_controls_decay(self, router, tokenizer, monkeypatch):
407+
# A shorter TTL makes the same idle fully decay P_alive -> unlock.
408+
monkeypatch.setenv("HEADROOM_NET_COST_POLICY", "1")
409+
monkeypatch.setenv("HEADROOM_NET_COST_CACHE_TTL_SECONDS", "60")
410+
messages = _messages(_tool_json(300), suffix_filler_words=40000)
411+
result = router.apply([dict(m) for m in messages], tokenizer, idle_seconds=59.0)
412+
assert _tool_slot_compressed(result, messages)
413+
assert "router:netcost_idle_compaction" in result.transforms_applied

0 commit comments

Comments
 (0)