From 0632eba6c3bdf5b030d794d3dfefa3c29543d2e8 Mon Sep 17 00:00:00 2001 From: Focused Instability <70747559+MrAshRhodes@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:14:30 +0200 Subject: [PATCH] fix(policy): correct warm-cache penalty in net_mutation_gain to (S + dT) (#903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #906. ## What Part of #904 (net-cost policy completion tracking). Follows up #856 / #857 with the corrected gain term raised in [this #856 comment](https://github.com/chopratejas/headroom/issues/856#issuecomment-4679706939) — prerequisite for P2 (pipeline consumption), which would otherwise wire in a formula that is always-pro-mutation by exactly `P_alive·(w−r)·ΔT`. ## Why the corrected form is right With a live cache, the ΔT tokens a mutation removes are **already cache-written** — keeping them costs only reads (`ΔT·r·R`), so a mutation cannot avoid a fresh write of them. Blending alive (`ΔT·r·R − (w−r)·S`) and dead (`ΔT·(w + r·(R−1))`, no suffix penalty) cases over `P_alive`: ``` gain = ΔT·(w + r·(R−1)) − P_alive·(w−r)·(S + ΔT) ``` Three independent confirmations: 1. **Direct cost check** (w=1.25, r=0.1, warm, ΔT=50K, S=10K, R=2): keeping costs 60K·0.1·2 = 12,000 in reads; mutating costs 10K·1.25 (suffix rewrite, the first of the R touches) + 10K·0.1 (remaining read) = 13,500 — mutation loses 1,500, matching the corrected gain of −1,500. The old form said +56,000. 2. **The issue's own anchors**: corrected break-even is exactly `R = 11.5·S/ΔT` → 2K/50K = 287.5 (~290, as the issue says) and 50K/10K = 2.3 — the spec text's anchor numbers can only be derived from the corrected penalty. The implemented form gave 276 and *negative*. 3. **Internal consistency**: `break_even_reads` already shipped with the ~11.5·S/ΔT shape; this PR reconciles `net_mutation_gain` with it (and drops break_even's stray −1 term). ## Behavior changes (formula is still dead code — nothing consumes it yet) - 50K-shave/10K-suffix/R=3 golden: +61,000 → **+3,500** (tight win, consistent with 2.3-read break-even). - 2K-shave/50K-suffix/R=10 golden: −53,200 → **−55,500**. - S=0 boundary: an edit of already-cached content with no suffix is profitable whenever ≥1 read remains (`gain = ΔT·r·R`), and exactly 0 at R=0 warm. Not-yet-cached (live-zone) content should bypass the formula — now documented on both implementations. Rust + Python goldens updated in lockstep: 13 Rust + 19 Python tests green. ## Next (separate PRs) - **P2**: flag-gated consumption (`HEADROOM_NET_COST_POLICY=1`) with decision telemetry. - **P3**: batch deep edits (reclaim threshold), idle-timer compaction near TTL lapse. Co-authored-by: integration-check --- .../headroom-core/src/compression_policy.rs | 81 ++++++++++++------- headroom/transforms/compression_policy.py | 16 +++- tests/test_compression_policy.py | 30 ++++--- 3 files changed, 80 insertions(+), 47 deletions(-) diff --git a/crates/headroom-core/src/compression_policy.rs b/crates/headroom-core/src/compression_policy.rs index 278d98cbc..4d9d786c6 100644 --- a/crates/headroom-core/src/compression_policy.rs +++ b/crates/headroom-core/src/compression_policy.rs @@ -239,21 +239,24 @@ impl CompressionPolicy { /// removes `delta_t` tokens from a message whose cached suffix is /// `suffix_tokens` long (#856). /// - /// Mutating message K invalidates every cached token after it: the - /// suffix is re-written once at the write multiplier instead of - /// being read at the read multiplier, costing - /// `P_alive · (w − r) · S`. In exchange, `delta_t` tokens are gone - /// from the current write and every one of the `expected_reads` - /// remaining reads of the chain, saving `ΔT · (w + r·(R − 1))`. + /// Mutating message K invalidates every cached token after it. When + /// the cache is warm the mutated ΔT tokens are themselves already + /// cache-written, so keeping them costs only reads (`ΔT · r · R`) + /// while mutating re-writes the suffix: alive-case saving is + /// `ΔT·r·R − (w−r)·S`. When the cache is dead there is no suffix + /// penalty and the full `ΔT·(w + r·(R−1))` is saved. Taking the + /// expectation over `P_alive`: /// - /// gain = ΔT · (w + r·(R − 1)) − P_alive · (w − r) · S + /// gain = ΔT · (w + r·(R − 1)) − P_alive · (w − r) · (S + ΔT) /// /// Sanity anchors (Anthropic w=1.25, r=0.1), matching the unit - /// tests below: a 2K shave under a 50K warm suffix needs ~276 + /// tests below: a 2K shave under a 50K warm suffix needs 287.5 /// remaining reads to pay off (rarely profitable); a 50K shave - /// under a 10K suffix is profitable from the first write (its - /// break-even read count is negative); a live-zone edit (S = 0) - /// is always profitable. + /// under a 10K suffix breaks even at 2.3 reads (profitable in any + /// session with a few turns left); an edit with S = 0 is profitable + /// whenever at least one read remains. Callers gating not-yet-cached + /// content (live-zone edits) should bypass this formula — it prices + /// mutations of content the cache has already written. /// /// Takes `&self` so a follow-up can apply per-mode margins; today /// the arithmetic is mode-independent. Inputs are clamped: @@ -276,7 +279,15 @@ impl CompressionPolicy { } else { p_alive.clamp(0.0, 1.0) }; - (delta_t as f32) * (w + r * (reads - 1.0)) - alive * (w - r) * (suffix_tokens as f32) + // Corrected warm-case penalty (#856 follow-up): when the cache is + // alive, the ΔT tokens are already cache-written, so keeping them + // costs only reads — a mutation can avoid at most ΔT·r·R, not a + // fresh write. Blending alive (ΔT·r·R − (w−r)·S) and dead + // (ΔT·(w + r·(R−1))) cases over P_alive gives a penalty over + // S + ΔT, not S alone. The looser ·S form overstated gain by + // P_alive·(w−r)·ΔT — always pro-mutation, largest for big shaves. + (delta_t as f32) * (w + r * (reads - 1.0)) + - alive * (w - r) * ((suffix_tokens as f32) + (delta_t as f32)) } /// Decision form of [`Self::net_mutation_gain`]: mutate iff the @@ -292,9 +303,13 @@ impl CompressionPolicy { } /// Remaining-read count at which a warm-cache (P_alive = 1) - /// mutation breaks even: + /// mutation breaks even. With the corrected penalty this is exactly /// - /// R = ((w − r) / r) · (S/ΔT − 1) ≈ 11.5 · S/ΔT for S ≫ ΔT + /// R = ((w − r) / r) · S/ΔT = 11.5 · S/ΔT (Anthropic 5-min) + /// + /// reproducing the #856 anchors precisely: 2K shave / 50K suffix → + /// 287.5 (~290 reads, rarely profitable); 50K shave / 10K suffix → + /// 2.3 (profitable in any session with a few turns left). /// /// Useful for decision telemetry ("this edit pays off if the /// session lasts N more turns"). Returns 0 when `delta_t` is 0 @@ -305,7 +320,7 @@ impl CompressionPolicy { } let w = CACHE_WRITE_MULTIPLIER; let r = CACHE_READ_MULTIPLIER; - ((w - r) / r) * ((suffix_tokens as f32) / (delta_t as f32) - 1.0) + ((w - r) / r) * ((suffix_tokens as f32) / (delta_t as f32)) } } @@ -414,31 +429,36 @@ mod tests { #[test] fn net_gain_small_shave_deep_suffix_is_loss() { // Shave 2K under a 50K warm suffix at R=10 remaining reads: - // 2000·(1.25 + 0.1·9) − 1.0·1.15·50000 = 4300 − 57500 = −53200. + // 2000·(1.25 + 0.1·9) − 1.0·1.15·52000 = 4300 − 59800 = −55500. let p = CompressionPolicy::for_mode(AuthMode::Payg); let gain = p.net_mutation_gain(2_000, 50_000, 10.0, 1.0); - assert!((gain - (-53_200.0)).abs() < 1.0, "gain = {gain}"); + assert!((gain - (-55_500.0)).abs() < 1.0, "gain = {gain}"); assert!(!p.should_mutate_deep(2_000, 50_000, 10.0, 1.0)); } #[test] fn net_gain_big_shave_shallow_suffix_is_win() { // Shave 50K under a 10K warm suffix at R=3: - // 50000·(1.25 + 0.1·2) − 1.0·1.15·10000 = 72500 − 11500 = 61000. + // 50000·(1.25 + 0.1·2) − 1.0·1.15·60000 = 72500 − 69000 = 3500. + // Tight but positive — consistent with the 2.3-read break-even. let p = CompressionPolicy::for_mode(AuthMode::Payg); let gain = p.net_mutation_gain(50_000, 10_000, 3.0, 1.0); - assert!((gain - 61_000.0).abs() < 1.0, "gain = {gain}"); + assert!((gain - 3_500.0).abs() < 1.0, "gain = {gain}"); assert!(p.should_mutate_deep(50_000, 10_000, 3.0, 1.0)); } #[test] - fn net_gain_live_zone_edit_always_profitable() { - // S = 0 derives the existing Subscription live-zone policy as a - // special case: nothing cached is invalidated, so any positive - // shave wins even at R=0 (gain = ΔT·(w − r) > 0). + fn net_gain_no_suffix_edit_profitable_with_reads_remaining() { + // S = 0: nothing cached after the edit is invalidated. Warm-case + // saving is the avoided rereads, ΔT·r·R — positive whenever at + // least one read remains. At R=0 with a warm cache the gain is + // exactly 0 (already written, never read again): the boundary + // where mutating is pointless rather than harmful. let p = CompressionPolicy::for_mode(AuthMode::Subscription); - assert!(p.should_mutate_deep(1, 0, 0.0, 1.0)); - assert!(p.should_mutate_deep(2_000, 0, 0.0, 1.0)); + assert!(p.should_mutate_deep(1, 0, 1.0, 1.0)); + assert!(p.should_mutate_deep(2_000, 0, 1.0, 1.0)); + let boundary = p.net_mutation_gain(2_000, 0, 0.0, 1.0); + assert!(boundary.abs() < f32::EPSILON, "boundary = {boundary}"); } #[test] @@ -472,13 +492,14 @@ mod tests { #[test] fn break_even_reads_matches_research_anchor() { - // R = 11.5·(S/ΔT − 1): 2K shave / 50K suffix → 11.5·24 = 276 - // (rarely profitable); 50K shave / 10K suffix → - // 11.5·(0.2 − 1) < 0 → profitable from the first read. + // R = 11.5·S/ΔT, the #856 anchors exactly: 2K shave / 50K + // suffix → 11.5·25 = 287.5 (rarely profitable); 50K shave / + // 10K suffix → 11.5·0.2 = 2.3 (profitable within a few turns). let p = CompressionPolicy::for_mode(AuthMode::Payg); let r = p.break_even_reads(2_000, 50_000); - assert!((r - 276.0).abs() < 0.5, "break-even = {r}"); - assert!(p.break_even_reads(50_000, 10_000) < 0.0); + assert!((r - 287.5).abs() < 0.5, "break-even = {r}"); + let shallow = p.break_even_reads(50_000, 10_000); + assert!((shallow - 2.3).abs() < 0.05, "break-even = {shallow}"); assert_eq!(p.break_even_reads(0, 10_000), 0.0); } } diff --git a/headroom/transforms/compression_policy.py b/headroom/transforms/compression_policy.py index 717cc56b8..626b40bc3 100644 --- a/headroom/transforms/compression_policy.py +++ b/headroom/transforms/compression_policy.py @@ -137,7 +137,12 @@ class CompressionPolicy: Mirrors ``CompressionPolicy::net_mutation_gain`` in the Rust crate (source of truth — see its docstring for the derivation):: - gain = dT * (w + r*(R - 1)) - P_alive * (w - r) * S + gain = dT * (w + r*(R - 1)) - P_alive * (w - r) * (S + dT) + + The warm-case penalty covers ``S + dT``: with a live cache the + ``dT`` tokens are already cache-written, so keeping them costs + only reads — a mutation avoids at most ``dT*r*R``, not a fresh + write. Inputs are clamped: ``delta_t``/``suffix_tokens`` to ``>= 0`` (the Rust signature takes ``u32``), ``expected_reads`` to @@ -152,7 +157,7 @@ class CompressionPolicy: # f32::max in the Rust source of truth — guard explicitly. reads = 0.0 if math.isnan(expected_reads) else max(expected_reads, 0.0) alive = 1.0 if math.isnan(p_alive) else min(max(p_alive, 0.0), 1.0) - return float(dt) * (w + r * (reads - 1.0)) - alive * (w - r) * float(suffix) + return float(dt) * (w + r * (reads - 1.0)) - alive * (w - r) * float(suffix + dt) def should_mutate_deep( self, @@ -169,7 +174,10 @@ class CompressionPolicy: """Remaining-read count at which a warm-cache (``p_alive=1``) mutation breaks even:: - R = ((w - r) / r) * (S/dT - 1) ~= 11.5 * S/dT for S >> dT + R = ((w - r) / r) * (S/dT) = 11.5 * S/dT (Anthropic 5-min) + + With the corrected penalty this reproduces the #856 anchors + exactly: 2K/50K -> 287.5, 50K/10K -> 2.3. Returns 0 when ``delta_t`` is ``<= 0`` (no savings — callers gate on ``delta_t > 0``; the Rust signature takes ``u32``). @@ -179,7 +187,7 @@ class CompressionPolicy: return 0.0 w = CACHE_WRITE_MULTIPLIER r = CACHE_READ_MULTIPLIER - return ((w - r) / r) * (float(max(0, suffix_tokens)) / float(delta_t) - 1.0) + return ((w - r) / r) * (float(max(0, suffix_tokens)) / float(delta_t)) def policy_for_mode(mode: AuthMode) -> CompressionPolicy: diff --git a/tests/test_compression_policy.py b/tests/test_compression_policy.py index da6a5284a..cac6f02f9 100644 --- a/tests/test_compression_policy.py +++ b/tests/test_compression_policy.py @@ -169,25 +169,29 @@ class TestNetCostFormula: """ def test_small_shave_deep_suffix_is_loss(self): - # 2000*(1.25 + 0.1*9) - 1.0*1.15*50000 = 4300 - 57500 = -53200. + # 2000*(1.25 + 0.1*9) - 1.0*1.15*52000 = 4300 - 59800 = -55500. p = policy_for_mode(AuthMode.PAYG) gain = p.net_mutation_gain(2_000, 50_000, 10.0, 1.0) - assert abs(gain - (-53_200.0)) < 1.0 + assert abs(gain - (-55_500.0)) < 1.0 assert not p.should_mutate_deep(2_000, 50_000, 10.0, 1.0) def test_big_shave_shallow_suffix_is_win(self): - # 50000*(1.25 + 0.1*2) - 1.0*1.15*10000 = 72500 - 11500 = 61000. + # 50000*(1.25 + 0.1*2) - 1.0*1.15*60000 = 72500 - 69000 = 3500. + # Tight but positive — consistent with the 2.3-read break-even. p = policy_for_mode(AuthMode.PAYG) gain = p.net_mutation_gain(50_000, 10_000, 3.0, 1.0) - assert abs(gain - 61_000.0) < 1.0 + assert abs(gain - 3_500.0) < 1.0 assert p.should_mutate_deep(50_000, 10_000, 3.0, 1.0) - def test_live_zone_edit_always_profitable(self): - # S = 0 derives the existing Subscription live-zone policy as a - # special case of the formula. + def test_no_suffix_edit_profitable_with_reads_remaining(self): + # S = 0: warm-case saving is the avoided rereads, dT*r*R — + # positive whenever at least one read remains. At R=0 with a + # warm cache the gain is exactly 0 (already written, never read + # again): pointless rather than harmful. p = policy_for_mode(AuthMode.SUBSCRIPTION) - assert p.should_mutate_deep(1, 0, 0.0, 1.0) - assert p.should_mutate_deep(2_000, 0, 0.0, 1.0) + assert p.should_mutate_deep(1, 0, 1.0, 1.0) + assert p.should_mutate_deep(2_000, 0, 1.0, 1.0) + assert abs(p.net_mutation_gain(2_000, 0, 0.0, 1.0)) < 1e-6 def test_cold_cache_ignores_suffix(self): # P_alive = 0 (TTL lapsed): the idle-timer compaction window. @@ -220,11 +224,11 @@ class TestNetCostFormula: assert p.net_mutation_gain(2_000, -1, 5.0, 1.0) == p.net_mutation_gain(2_000, 0, 5.0, 1.0) def test_break_even_reads_matches_research_anchor(self): - # R = 11.5*(S/dT - 1): 2K/50K -> 276; 50K/10K -> negative - # (profitable from the first read); dT=0 -> 0. + # R = 11.5*S/dT, the #856 anchors exactly: 2K/50K -> 287.5; + # 50K/10K -> 2.3; dT=0 -> 0. p = policy_for_mode(AuthMode.PAYG) - assert abs(p.break_even_reads(2_000, 50_000) - 276.0) < 0.5 - assert p.break_even_reads(50_000, 10_000) < 0.0 + assert abs(p.break_even_reads(2_000, 50_000) - 287.5) < 0.5 + assert abs(p.break_even_reads(50_000, 10_000) - 2.3) < 0.05 assert p.break_even_reads(0, 10_000) == 0.0 def test_constants_match_rust(self):