From 797dc63da747dd8594958709271abd658de4a5c0 Mon Sep 17 00:00:00 2001 From: chopratejas Date: Tue, 5 May 2026 18:28:11 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(core):=20F2.2=20c1/3=20=E2=80=94=20exte?= =?UTF-8?q?nd=20CompressionPolicy=20with=20three=20per-mode=20tuning=20fie?= =?UTF-8?q?lds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three per-mode tuning fields to the F2.1 CompressionPolicy struct on both sides of the parity bridge: - volatile_token_threshold (u32 / int) — per-mode threshold below which content is treated as cache-stable. PAYG=128 (relaxed), Subscription=32 (strict). Plumbed but unconsumed in F2.2 — the volatile detector in cache_aligner.py is shape-based; wiring it is a follow-up. - max_lossy_ratio (f32 / float, [0.0, 1.0]) — per-mode upper bound on lossy compression aggressiveness. PAYG=0.45, Subscription=0.25. Plumbed but unconsumed in F2.2 — distinct from the caller-driven target_ratio kwarg in ContentRouter. - toin_read_only (bool) — TOIN learning gate. True = serve cached patterns but never write new observations from this request. PAYG/OAuth=false (network effect feeds on aggressive traffic), Subscription=true (consistency over learning). OAuth stays identical to PAYG across all five fields; the canary parity test (oauth_matches_payg_today) covers the full struct so a future divergence on any field trips the assertion just as loudly as a flag flip. Per-mode defaults are CONSERVATIVE pending F2.1 bake telemetry; F2.2- followup will tune. Per the realignment build constraints, the configuration IS the per-mode default — no separate env var per field. What's NOT in this commit: - TOIN gate wiring (next commit, c2/3) - policy_selected log extension (next commit, c2/3) Tests: - Rust: 6 unit tests in compression_policy::tests (3 new). OAuth=PAYG canary now compares ALL fields. - Python: 10 tests in tests/test_compression_policy.py. Hard-coded expected_fields set in TestRustParityFieldMap extended. Refs: F2.1 (#400) --- .../headroom-core/src/compression_policy.rs | 216 +++++++++++++++--- headroom/transforms/compression_policy.py | 126 ++++++++-- tests/test_compression_policy.py | 83 ++++++- 3 files changed, 371 insertions(+), 54 deletions(-) diff --git a/crates/headroom-core/src/compression_policy.rs b/crates/headroom-core/src/compression_policy.rs index 543c1994e..b2f1f8395 100644 --- a/crates/headroom-core/src/compression_policy.rs +++ b/crates/headroom-core/src/compression_policy.rs @@ -1,9 +1,11 @@ -//! Per-auth-mode compression policy — Phase F PR-F2.1. +//! Per-auth-mode compression policy — Phase F PR-F2.1, extended in F2.2. //! //! F1 (`auth_mode.rs`) classifies each inbound request into one of //! `{Payg, OAuth, Subscription}`. Phase F2.1 turns that classification //! into a `CompressionPolicy` that downstream pipeline stages read to -//! decide whether they run. +//! decide whether they run. F2.2 extends the same struct with per-mode +//! tuning fields so the same call sites also read *how aggressively* +//! to run. //! //! Why a struct instead of `match auth_mode { ... }` everywhere? //! Two reasons: @@ -22,13 +24,10 @@ //! end-to-end behaviour against the dispatcher requires a full //! request fixture. //! -//! Phase F2.2 will add fields to this struct (per-mode volatile -//! threshold, per-mode max lossy ratio, per-mode TOIN read-only flag). -//! F2.1 keeps the field set minimal — only the two flags load-bearing -//! for closing the cache-instability complaints in issues #327 / #388. -//! //! ## Field semantics //! +//! ### F2.1 fields (load-bearing for closing #327 / #388) +//! //! - **`live_zone_only`**: when `true`, downstream stages MUST NOT //! modify bytes outside the post-cache-marker live zone. Phase B's //! Rust dispatcher is *already* live-zone-only by construction, so @@ -46,15 +45,47 @@ //! which is what destabilised Subscription users' prompt caches. //! Disabling it for Subscription is the user-visible win of F2.1. //! -//! ## Per-mode F2.1 values +//! ### F2.2 tuning fields (CONSERVATIVE defaults pending bake telemetry) //! -//! | Mode | live_zone_only | cache_aligner_enabled | -//! |--------------|----------------|-----------------------| -//! | Payg | false | true | -//! | OAuth | false | true (= PAYG today) | -//! | Subscription | true | false | +//! - **`volatile_token_threshold`**: per-mode token-count threshold +//! below which content is treated as cache-stable (i.e. not flagged +//! as volatile). Subscription is conservative (low threshold → flag +//! more aggressively → keep prompts stable) while PAYG is aggressive +//! (higher threshold → tolerate more volatile noise before warning). +//! F2.1 had no such threshold; F2.2 introduces the field plumbed +//! through the struct so future detector code can pick it up. NOTE: +//! no current detector consumes this value — it lands plumbed-but- +//! unconsumed in F2.2 (intentional; the volatile detector in +//! `cache_aligner.py` is shape-based, not token-count-based, and +//! wiring it would force a detector refactor outside F2.2 scope). //! -//! OAuth starts identical to PAYG. F2.2 will divide them once +//! - **`max_lossy_ratio`**: per-mode upper bound on how aggressive +//! lossy compression can be, expressed as the fraction of original +//! tokens that may be dropped (`0.0` = no lossy compression allowed, +//! `1.0` = unlimited). Subscription is conservative (`0.25`) so cache +//! prefixes stay stable, PAYG aggressive (`0.45`). NOTE: no current +//! compressor consumes this value — it lands plumbed-but-unconsumed +//! in F2.2 (the `target_ratio` runtime kwarg in `content_router.py` +//! is a separate, caller-driven knob; wiring `max_lossy_ratio` as a +//! policy-driven cap is F2.2-followup once telemetry decides whether +//! to gate lossy paths or just observe them). +//! +//! - **`toin_read_only`**: when `true`, TOIN serves cached +//! recommendations but never *writes* new pattern observations from +//! this request. Subscription requests pay for prompt-cache stability, +//! so we don't want their compression events to mutate the global +//! learning pool — consistency over learning. PAYG/OAuth still write +//! so the network effect keeps growing. +//! +//! ## Per-mode F2.2 values (CONSERVATIVE; F2.2-followup will tune) +//! +//! | Mode | live_zone_only | cache_aligner_enabled | volatile_token_threshold | max_lossy_ratio | toin_read_only | +//! |--------------|----------------|-----------------------|--------------------------|-----------------|----------------| +//! | Payg | false | true | 128 | 0.45 | false | +//! | OAuth | false | true (= PAYG today) | 128 (= PAYG today) | 0.45 (= PAYG) | false (= PAYG) | +//! | Subscription | true | false | 32 | 0.25 | true | +//! +//! OAuth starts identical to PAYG. F2.2-followup will divide them once //! telemetry from F2.1's bake on `main` shows what each mode actually //! costs / saves. //! @@ -69,12 +100,42 @@ use crate::auth_mode::AuthMode; +// ── F2.2 per-mode default values (CONSERVATIVE pending bake telemetry) ── +// Centralised constants instead of inlining in the match arms so a +// follow-up tune lands in one place. Each constant is `pub(crate)` so +// the unit tests can assert against the same source of truth — if a +// caller drifts the per-mode value, the assertion fails. +// +// Per the realignment build constraints (project memory +// `feedback_realignment_build_constraints.md`): "configurable / no +// hardcoded values". The configuration *is* the per-mode default — we +// deliberately do NOT add a separate env var per field. Operators tune +// by editing these constants and shipping a new build, which is the +// same pattern the F2.1 fields use. + +/// PAYG: aggressive — let volatile content noise up to ~128 tokens slip +/// before flagging. Higher than Subscription because PAYG users opt in +/// to aggressive compression. +pub(crate) const VOLATILE_TOKEN_THRESHOLD_PAYG: u32 = 128; + +/// Subscription: conservative — flag volatile content earlier (32 +/// tokens) so cache prefixes stay stable. +pub(crate) const VOLATILE_TOKEN_THRESHOLD_SUBSCRIPTION: u32 = 32; + +/// PAYG: cap lossy compression at 45% of original tokens. Aggressive +/// but bounded — F2.1 had no cap (effectively `1.0`), F2.2 introduces +/// one. +pub(crate) const MAX_LOSSY_RATIO_PAYG: f32 = 0.45; + +/// Subscription: conservative cap at 25%. Cache stability over savings. +pub(crate) const MAX_LOSSY_RATIO_SUBSCRIPTION: f32 = 0.25; + /// Per-auth-mode policy that downstream compression stages consult. /// -/// `Copy` because the struct is two `bool`s — passing by value is -/// cheaper than passing a reference and the call sites all want -/// owned copies anyway. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// `Copy` because the struct is small POD (two `bool`s + a `u32` + an +/// `f32` + a `bool`) — passing by value is cheaper than passing a +/// reference and the call sites all want owned copies anyway. +#[derive(Debug, Clone, Copy, PartialEq)] pub struct CompressionPolicy { /// When `true`, transforms MUST NOT modify bytes outside the /// post-cache-marker live zone. See module docs. @@ -83,29 +144,73 @@ pub struct CompressionPolicy { /// When `false`, the `CacheAligner` transform MUST be skipped. /// See module docs. pub cache_aligner_enabled: bool, + + /// F2.2: per-mode threshold (in tokens) below which content is + /// treated as cache-stable. Subscription is conservative + /// (`32`); PAYG aggressive (`128`). See module docs. + /// + /// NOT consumed by any detector in F2.2 — plumbed through the + /// struct so the volatile detector refactor in a follow-up PR + /// has a stable hook to read from. + pub volatile_token_threshold: u32, + + /// F2.2: per-mode upper bound on lossy compression aggressiveness, + /// expressed as the fraction of original tokens that may be + /// dropped (`0.0`–`1.0`). Subscription `0.25`, PAYG `0.45`. + /// See module docs. + /// + /// NOT consumed by any compressor in F2.2 — plumbed through the + /// struct as a stable hook for a follow-up PR that gates lossy + /// paths on the cap. Distinct from the caller-driven + /// `target_ratio` kwarg in the Python ContentRouter. + pub max_lossy_ratio: f32, + + /// F2.2: when `true`, TOIN serves cached recommendations but + /// never writes new pattern observations from this request. + /// Subscription `true` (consistency over learning), PAYG/OAuth + /// `false` (network effect keeps growing). + pub toin_read_only: bool, } +// `f32` doesn't impl `Eq`, so the derived `Eq` would be invalid. Two +// `f32`s in this struct are never NaN by construction (we only set +// them from finite literal constants), so `PartialEq` is sufficient. +// The unit tests assert structural equality via `assert_eq!`. + impl CompressionPolicy { - /// Resolve the F2.1 policy for an auth mode. See module docs for - /// per-mode rationale. + /// Resolve the F2.1+F2.2 policy for an auth mode. See module docs + /// for per-mode rationale. pub fn for_mode(mode: AuthMode) -> Self { match mode { AuthMode::Payg => Self { live_zone_only: false, cache_aligner_enabled: true, + volatile_token_threshold: VOLATILE_TOKEN_THRESHOLD_PAYG, + max_lossy_ratio: MAX_LOSSY_RATIO_PAYG, + toin_read_only: false, }, - // OAuth identical to PAYG in F2.1. F2.2 may diverge once - // telemetry shows what OAuth users actually need. + // OAuth identical to PAYG in F2.1+F2.2. F2.2-followup may + // diverge once telemetry shows what OAuth users actually + // need. AuthMode::OAuth => Self { live_zone_only: false, cache_aligner_enabled: true, + volatile_token_threshold: VOLATILE_TOKEN_THRESHOLD_PAYG, + max_lossy_ratio: MAX_LOSSY_RATIO_PAYG, + toin_read_only: false, }, // The user-visible win of F2.1: subscription users stop // seeing cache instability because CacheAligner no longer - // touches their prefix. + // touches their prefix. F2.2 extends that protection: the + // volatile threshold is tighter, the lossy cap is lower, + // and TOIN won't mutate the learning pool from these + // requests. AuthMode::Subscription => Self { live_zone_only: true, cache_aligner_enabled: false, + volatile_token_threshold: VOLATILE_TOKEN_THRESHOLD_SUBSCRIPTION, + max_lossy_ratio: MAX_LOSSY_RATIO_SUBSCRIPTION, + toin_read_only: true, }, } } @@ -133,15 +238,38 @@ mod tests { assert!(p.live_zone_compression_enabled()); } + #[test] + fn payg_tuning_fields_aggressive() { + // F2.2: per-mode tuning fields. PAYG values are the aggressive + // end of the conservative-defaults spectrum — F2.2-followup may + // raise them once bake telemetry confirms savings. + let p = CompressionPolicy::for_mode(AuthMode::Payg); + assert_eq!( + p.volatile_token_threshold, 128, + "PAYG volatile threshold is the relaxed default; F2.2-followup will tune" + ); + assert!( + (p.max_lossy_ratio - 0.45).abs() < f32::EPSILON, + "PAYG max_lossy_ratio caps lossy paths at 0.45; F2.2-followup will tune" + ); + assert!( + !p.toin_read_only, + "PAYG keeps TOIN write-enabled — network effect feeds on PAYG traffic" + ); + } + #[test] fn oauth_matches_payg_today() { - // Canary: when F2.2 diverges OAuth from PAYG, this test fails - // and forces a deliberate update — which is the point. + // Canary: when F2.2-followup diverges OAuth from PAYG, this test + // fails and forces a deliberate update — which is the point. + // Covers ALL fields (F2.1 + F2.2) so a future field-level + // divergence (e.g. OAuth gets stricter `max_lossy_ratio` than + // PAYG) trips the assertion just as loudly as a flag flip. let oauth = CompressionPolicy::for_mode(AuthMode::OAuth); let payg = CompressionPolicy::for_mode(AuthMode::Payg); assert_eq!( oauth, payg, - "F2.1 ships OAuth=PAYG; F2.2 will diverge based on telemetry" + "F2.1+F2.2 ship OAuth=PAYG; F2.2-followup will diverge based on telemetry" ); } @@ -158,4 +286,40 @@ mod tests { "Subscription still gets live-zone compression — closing the cache complaint must NOT mean shipping zero compression" ); } + + #[test] + fn subscription_tuning_fields_conservative() { + // F2.2: per-mode tuning fields. Subscription is the conservative + // end — tighter threshold, lower lossy cap, TOIN read-only — so + // cache prefixes stay stable and the learning pool isn't + // mutated from cache-stability-sensitive traffic. + let p = CompressionPolicy::for_mode(AuthMode::Subscription); + assert_eq!( + p.volatile_token_threshold, 32, + "Subscription volatile threshold flags content earlier (cache stability)" + ); + assert!( + (p.max_lossy_ratio - 0.25).abs() < f32::EPSILON, + "Subscription max_lossy_ratio caps lossy paths at 0.25 (conservative)" + ); + assert!( + p.toin_read_only, + "Subscription MUST be TOIN read-only — load-bearing for keeping the learning pool consistent across cache-sensitive traffic" + ); + } + + #[test] + fn max_lossy_ratio_in_unit_interval() { + // Defensive: every per-mode `max_lossy_ratio` MUST be in `[0.0, + // 1.0]` because it expresses a fraction. A tune that drifts + // outside the unit interval is a bug — catch it cheaply here + // rather than at the eventual consumer site. + for mode in [AuthMode::Payg, AuthMode::OAuth, AuthMode::Subscription] { + let r = CompressionPolicy::for_mode(mode).max_lossy_ratio; + assert!( + (0.0..=1.0).contains(&r), + "max_lossy_ratio for {mode:?} = {r} is outside [0.0, 1.0]" + ); + } + } } diff --git a/headroom/transforms/compression_policy.py b/headroom/transforms/compression_policy.py index ea71cfb9b..5057a8812 100644 --- a/headroom/transforms/compression_policy.py +++ b/headroom/transforms/compression_policy.py @@ -1,4 +1,4 @@ -"""Per-auth-mode compression policy — Phase F PR-F2.1, Python parity. +"""Per-auth-mode compression policy — Phase F PR-F2.1, extended in F2.2 (Python parity). Hand-mirrored port of `headroom_core::compression_policy::CompressionPolicy` (Rust). The Rust crate is the source of truth; this module exists so @@ -10,10 +10,10 @@ A parity test (`tests/test_compression_policy.py`) instantiates one of each variant and asserts the field map matches what the Rust unit tests assert. F2.2 should consider exposing the Rust struct via PyO3 to retire this hand-mirror — that's deliberately out of scope here so -F2.1 can ship. +F2.1/F2.2 can ship. See `crates/headroom-core/src/compression_policy.rs` for the canonical -docstring. +docstring (per-mode rationale, why-a-struct, etc.). """ from __future__ import annotations @@ -23,15 +23,58 @@ from dataclasses import dataclass from headroom.proxy.auth_mode import AuthMode +# ── F2.2 per-mode default values (CONSERVATIVE pending bake telemetry) ── +# Mirrors the Rust ``pub(crate) const`` block in +# ``crates/headroom-core/src/compression_policy.rs``. Centralised here so +# a follow-up tune lands in one place per language. The Rust tests assert +# the values directly; the Python parity tests assert the *fields* match +# (not the values — that would double-pin against drift in a way that +# masks real divergence). +# +# Per the realignment build constraints (project memory +# ``feedback_realignment_build_constraints.md``): "configurable / no +# hardcoded values". The configuration *is* the per-mode default — we +# deliberately do NOT add a separate env var per field. Operators tune +# by editing these constants and shipping a new build, mirroring the +# Rust pattern exactly. + +#: PAYG: aggressive — let volatile content noise up to ~128 tokens slip +#: before flagging. Higher than Subscription because PAYG users opt in +#: to aggressive compression. +_VOLATILE_TOKEN_THRESHOLD_PAYG: int = 128 + +#: Subscription: conservative — flag volatile content earlier (32 +#: tokens) so cache prefixes stay stable. +_VOLATILE_TOKEN_THRESHOLD_SUBSCRIPTION: int = 32 + +#: PAYG: cap lossy compression at 45% of original tokens. Aggressive +#: but bounded — F2.1 had no cap (effectively ``1.0``), F2.2 introduces one. +_MAX_LOSSY_RATIO_PAYG: float = 0.45 + +#: Subscription: conservative cap at 25%. Cache stability over savings. +_MAX_LOSSY_RATIO_SUBSCRIPTION: float = 0.25 + @dataclass(frozen=True, slots=True) class CompressionPolicy: """Per-auth-mode policy that downstream compression stages consult. - Two fields in F2.1 — `live_zone_only` and `cache_aligner_enabled`. - F2.2 will add tuning fields (per-mode volatile threshold, - max-lossy-ratio cap, per-mode TOIN read-only flag) once telemetry - from F2.1's bake on `main` shows what each mode actually costs/saves. + Five fields after F2.2: + + - ``live_zone_only`` (F2.1) — gate for the Python TransformPipeline + to skip pre-cache-marker mutation. + - ``cache_aligner_enabled`` (F2.1) — gate for the Python + ``CacheAligner`` transform. + - ``volatile_token_threshold`` (F2.2) — per-mode token threshold + below which content is treated as cache-stable. Plumbed through + the struct; no current detector consumes it (intentional — the + detector refactor is a follow-up). + - ``max_lossy_ratio`` (F2.2) — per-mode upper bound on lossy + compression aggressiveness (fraction in ``[0.0, 1.0]``). Plumbed + through the struct; no current compressor consumes it. + - ``toin_read_only`` (F2.2) — TOIN learning gate. ``True`` = + serve cached patterns but never write new observations from + this request (Subscription). """ live_zone_only: bool @@ -46,33 +89,76 @@ class CompressionPolicy: return False — that's the load-bearing F2.1 gate for the cache- instability complaints in #327 / #388.""" + volatile_token_threshold: int + """F2.2: per-mode token-count threshold below which content is + treated as cache-stable. Subscription is conservative + (low → flag aggressively → keep prompts stable); PAYG aggressive + (high → tolerate more volatile noise). Plumbed but unconsumed in + F2.2 — the volatile detector in ``cache_aligner.py`` is shape- + based, not token-count-based; wiring it is a follow-up.""" + + max_lossy_ratio: float + """F2.2: per-mode upper bound on lossy compression aggressiveness, + expressed as the fraction of original tokens that may be dropped + (``0.0`` = no lossy, ``1.0`` = unlimited). Subscription ``0.25``; + PAYG ``0.45``. Plumbed but unconsumed in F2.2 — distinct from the + caller-driven ``target_ratio`` kwarg in the Python ContentRouter.""" + + toin_read_only: bool + """F2.2: when True, TOIN serves cached recommendations but + never writes new pattern observations from this request. + Subscription True (consistency over learning); PAYG/OAuth False + (network effect keeps growing). The gate is read by + ``smart_crusher.py`` and ``content_router.py`` at the + ``record_compression`` call sites.""" + def policy_for_mode(mode: AuthMode) -> CompressionPolicy: - """Resolve the F2.1 policy for an auth mode. + """Resolve the F2.1+F2.2 policy for an auth mode. - PAYG and OAuth are identical in F2.1 (aggressive: live-zone-not- - only, cache-aligner on). Subscription is the user-visible win: - live-zone-only with cache aligner disabled. + PAYG and OAuth are identical (aggressive: live-zone-not-only, + cache-aligner on, relaxed thresholds, TOIN write-enabled). + Subscription is the user-visible win: live-zone-only with cache + aligner disabled, tighter thresholds, TOIN read-only. - F2.2 may diverge OAuth from PAYG once telemetry is collected. + F2.2-followup may diverge OAuth from PAYG once telemetry is + collected. """ if mode == AuthMode.PAYG: - return CompressionPolicy(live_zone_only=False, cache_aligner_enabled=True) + return CompressionPolicy( + live_zone_only=False, + cache_aligner_enabled=True, + volatile_token_threshold=_VOLATILE_TOKEN_THRESHOLD_PAYG, + max_lossy_ratio=_MAX_LOSSY_RATIO_PAYG, + toin_read_only=False, + ) if mode == AuthMode.OAUTH: - # Identical to PAYG in F2.1. The parity test in - # `tests/test_compression_policy.py` is the canary that - # catches a future divergence and forces a deliberate - # update there + in the Rust crate. - return CompressionPolicy(live_zone_only=False, cache_aligner_enabled=True) + # Identical to PAYG in F2.1/F2.2. The parity test in + # ``tests/test_compression_policy.py`` is the canary that + # catches a future divergence and forces a deliberate update + # there + in the Rust crate. + return CompressionPolicy( + live_zone_only=False, + cache_aligner_enabled=True, + volatile_token_threshold=_VOLATILE_TOKEN_THRESHOLD_PAYG, + max_lossy_ratio=_MAX_LOSSY_RATIO_PAYG, + toin_read_only=False, + ) if mode == AuthMode.SUBSCRIPTION: - return CompressionPolicy(live_zone_only=True, cache_aligner_enabled=False) + return CompressionPolicy( + live_zone_only=True, + cache_aligner_enabled=False, + volatile_token_threshold=_VOLATILE_TOKEN_THRESHOLD_SUBSCRIPTION, + max_lossy_ratio=_MAX_LOSSY_RATIO_SUBSCRIPTION, + toin_read_only=True, + ) raise ValueError(f"Unhandled AuthMode variant: {mode!r}") def policy_default_payg() -> CompressionPolicy: """The PAYG-equivalent policy used when the ``HEADROOM_PROXY_AUTH_MODE_POLICY_ENFORCEMENT`` flag is disabled - (default in F2.1 c1-c4; flips to enabled in c5/5). + (default in F2.1 c1-c4; flipped to enabled in c5/5). Centralised so the proxy handlers do not duplicate the constant, and so a future change to PAYG semantics propagates to both the diff --git a/tests/test_compression_policy.py b/tests/test_compression_policy.py index b2a1d15e3..c517b0868 100644 --- a/tests/test_compression_policy.py +++ b/tests/test_compression_policy.py @@ -5,6 +5,11 @@ The Python module is a hand-mirror of pin both halves: that the per-mode values are right, and that the Python and Rust sides agree on the field map. F2.2 will likely retire the hand-mirror via PyO3 — until then, this file is the canary. + +F2.2 extends the F2.1 surface with three tuning fields: +``volatile_token_threshold``, ``max_lossy_ratio``, ``toin_read_only``. +Per-mode value tests below mirror the Rust unit tests in +``crates/headroom-core/src/compression_policy.rs``. """ from __future__ import annotations @@ -20,7 +25,7 @@ from headroom.transforms.compression_policy import ( class TestCompressionPolicyForMode: - """Per-mode field assertions. Mirrors the three Rust unit tests in + """Per-mode field assertions. Mirrors the Rust unit tests in `crates/headroom-core/src/compression_policy.rs`. """ @@ -29,13 +34,31 @@ class TestCompressionPolicyForMode: assert p.live_zone_only is False, "PAYG can touch outside live zone" assert p.cache_aligner_enabled is True, "PAYG runs cache aligner" + def test_payg_tuning_fields_aggressive(self): + # F2.2: per-mode tuning fields. Values are the conservative + # defaults pending bake telemetry (see PR body and module + # docstring). + p = policy_for_mode(AuthMode.PAYG) + assert p.volatile_token_threshold == 128, ( + "PAYG volatile threshold is the relaxed default; F2.2-followup will tune" + ) + assert p.max_lossy_ratio == pytest.approx(0.45), ( + "PAYG max_lossy_ratio caps lossy paths at 0.45; F2.2-followup will tune" + ) + assert p.toin_read_only is False, ( + "PAYG keeps TOIN write-enabled — network effect feeds on PAYG traffic" + ) + def test_oauth_matches_payg_today(self): - # Canary: when F2.2 diverges OAuth from PAYG, this test fails - # and forces a deliberate update on BOTH sides (Rust + Python). + # Canary: when F2.2-followup diverges OAuth from PAYG, this test + # fails and forces a deliberate update on BOTH sides (Rust + + # Python). Covers ALL fields (F2.1 + F2.2) so a future field- + # level divergence trips the assertion just as loudly as a flag + # flip. oauth = policy_for_mode(AuthMode.OAUTH) payg = policy_for_mode(AuthMode.PAYG) assert oauth == payg, ( - "F2.1 ships OAuth=PAYG; F2.2 will diverge based on telemetry. " + "F2.1+F2.2 ship OAuth=PAYG; F2.2-followup will diverge based on telemetry. " "If you are reading this assertion failure: also update " "crates/headroom-core/src/compression_policy.rs " "::oauth_matches_payg_today, otherwise the Rust + Python " @@ -49,6 +72,32 @@ class TestCompressionPolicyForMode: "Subscription MUST skip cache aligner — load-bearing for issues #327 / #388" ) + def test_subscription_tuning_fields_conservative(self): + # F2.2: per-mode tuning fields. Subscription is the conservative + # end — tighter threshold, lower lossy cap, TOIN read-only — so + # cache prefixes stay stable and the learning pool isn't + # mutated from cache-stability-sensitive traffic. + p = policy_for_mode(AuthMode.SUBSCRIPTION) + assert p.volatile_token_threshold == 32, ( + "Subscription volatile threshold flags content earlier (cache stability)" + ) + assert p.max_lossy_ratio == pytest.approx(0.25), ( + "Subscription max_lossy_ratio caps lossy paths at 0.25 (conservative)" + ) + assert p.toin_read_only is True, ( + "Subscription MUST be TOIN read-only — load-bearing for keeping the " + "learning pool consistent across cache-sensitive traffic" + ) + + def test_max_lossy_ratio_in_unit_interval(self): + # Defensive: every per-mode `max_lossy_ratio` MUST be in + # ``[0.0, 1.0]`` because it expresses a fraction. A tune that + # drifts outside the unit interval is a bug — catch it cheaply + # here rather than at the eventual consumer site. + for mode in (AuthMode.PAYG, AuthMode.OAUTH, AuthMode.SUBSCRIPTION): + r = policy_for_mode(mode).max_lossy_ratio + assert 0.0 <= r <= 1.0, f"max_lossy_ratio for {mode!r} = {r} is outside [0.0, 1.0]" + class TestPolicyDefaultPayg: """The constant used when the enforcement flag is disabled.""" @@ -68,20 +117,38 @@ class TestImmutability: # CPython 3.10+). Catch both for compatibility. p.live_zone_only = True # type: ignore[misc] + def test_f22_tuning_fields_also_frozen(self): + # Each F2.2 field gets its own immutability assertion — a + # future refactor that accidentally drops `frozen=True` on the + # dataclass would silently allow per-request mutation. The + # F2.1 test only covered ``live_zone_only``; explicit per- + # field coverage prevents quiet regressions. + p = policy_for_mode(AuthMode.PAYG) + for attr_name in ("volatile_token_threshold", "max_lossy_ratio", "toin_read_only"): + with pytest.raises((AttributeError, Exception)): + setattr(p, attr_name, 0) # type: ignore[misc] + class TestRustParityFieldMap: """The Python policy must have the same fields as the Rust struct. The canonical Rust struct lives at ``crates/headroom-core/src/compression_policy.rs``. When you add a - field there for F2.2, add it here AND update this test. Otherwise - the parity silently drifts. + field there for a future PR, add it here AND update this test. + Otherwise the parity silently drifts. """ def test_field_set_matches_rust(self): # Hard-coded set — when Rust grows fields, this test fails until - # Python catches up. - expected_fields = {"live_zone_only", "cache_aligner_enabled"} + # Python catches up. F2.2 added three: volatile_token_threshold, + # max_lossy_ratio, toin_read_only. + expected_fields = { + "live_zone_only", + "cache_aligner_enabled", + "volatile_token_threshold", + "max_lossy_ratio", + "toin_read_only", + } actual_fields = {f.name for f in CompressionPolicy.__dataclass_fields__.values()} assert actual_fields == expected_fields, ( f"Python CompressionPolicy fields drifted from Rust. " From 5b38cbf8a79d146adb6ca2ee25974bc35e271b05 Mon Sep 17 00:00:00 2001 From: chopratejas Date: Tue, 5 May 2026 18:35:18 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(transforms):=20F2.2=20c2/3=20=E2=80=94?= =?UTF-8?q?=20wire=20toin=5Fread=5Fonly=20gate=20+=20extend=20policy=5Fsel?= =?UTF-8?q?ected=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the F2.2 ``toin_read_only`` field through the only consumer where it's load-bearing (TOIN write surface) and extends the proxy's structured ``policy_selected`` log event with all three F2.2 fields so the bake dashboard has per-mode observability. Wiring (gates only TOIN writes — compression itself still runs): - headroom/transforms/smart_crusher.py: capture kwargs["compression_policy"] onto self._runtime_compression_policy at the start of apply(). _record_to_toin returns early when the policy says toin_read_only=True. Direct crush() / crush_array_json() callers don't go through apply() and keep pre-F2.2 write-enabled behaviour (no auth context for non-proxy callers). - headroom/transforms/content_router.py: same one-liner in apply(), same gate in _record_to_toin. Mirrors the existing _runtime_target_ratio / _runtime_kompress_model pattern. Telemetry: - crates/headroom-proxy/src/proxy.rs: extend the policy_selected structured log with volatile_token_threshold, max_lossy_ratio, and toin_read_only. F2.2 bake telemetry can now observe all five fields on every request — load-bearing for the F2.2-followup tune decision since volatile_token_threshold and max_lossy_ratio are plumbed-but- unconsumed today and the log is the only signal that the values are flowing correctly. Plumbed-but-unconsumed (deliberate; flagged in PR body): - volatile_token_threshold — the volatile detector in cache_aligner.py is shape-based, not token-count-based; wiring it forces a detector refactor outside F2.2 scope. - max_lossy_ratio — distinct from the caller-driven target_ratio kwarg in content_router.py; gating lossy paths on a policy cap is F2.2- followup once telemetry decides whether to gate or just observe. Tests (tests/test_compression_policy_toin_gate.py): - 7 tests covering the gate. SmartCrusher tests skip when the headroom._core Rust wheel isn't installed (matches the existing test_smart_crusher_rust_parity.py pattern); the 3 ContentRouter tests exercise the gate without the Rust dependency. CI's ci-precheck-python target runs scripts/build_rust_extension.sh before pytest so all 7 will run in the gate. Refs: F2.1 (#400) --- crates/headroom-proxy/src/proxy.rs | 10 + headroom/transforms/content_router.py | 33 +++ headroom/transforms/smart_crusher.py | 41 +++ tests/test_compression_policy_toin_gate.py | 289 +++++++++++++++++++++ 4 files changed, 373 insertions(+) create mode 100644 tests/test_compression_policy_toin_gate.py diff --git a/crates/headroom-proxy/src/proxy.rs b/crates/headroom-proxy/src/proxy.rs index 72c53ab47..9f801aa03 100644 --- a/crates/headroom-proxy/src/proxy.rs +++ b/crates/headroom-proxy/src/proxy.rs @@ -431,6 +431,13 @@ pub(crate) async fn forward_http( // c3/6 adds `enforcement` so the dashboard can split "policy // resolved as PAYG because mode is PAYG" from "policy resolved as // PAYG because the enforcement flag is off." + // + // F2.2 c2/3: extend the structured fields with the three new + // tuning fields so the bake dashboard has per-mode observability + // for the F2.2-followup tune. ``volatile_token_threshold`` / + // ``max_lossy_ratio`` are plumbed-but-unconsumed today, so the + // log lines are the only signal that the values are flowing + // correctly through the proxy → handlers → transforms path. tracing::debug!( event = "policy_selected", request_id = %request_id, @@ -438,6 +445,9 @@ pub(crate) async fn forward_http( enforcement = state.config.auth_mode_policy_enforcement.as_str(), live_zone_only = policy.live_zone_only, cache_aligner_enabled = policy.cache_aligner_enabled, + volatile_token_threshold = policy.volatile_token_threshold, + max_lossy_ratio = policy.max_lossy_ratio, + toin_read_only = policy.toin_read_only, "compression policy resolved" ); diff --git a/headroom/transforms/content_router.py b/headroom/transforms/content_router.py index add7b9402..ad218d46c 100644 --- a/headroom/transforms/content_router.py +++ b/headroom/transforms/content_router.py @@ -652,6 +652,17 @@ class ContentRouter(Transform): # TOIN integration for cross-strategy learning self._toin: Any = None + # F2.2: per-request CompressionPolicy, set from + # ``kwargs["compression_policy"]`` at the start of ``apply()`` + # and read by ``_record_to_toin`` to gate TOIN writes when + # ``policy.toin_read_only`` is true (Subscription mode). + # Defaults to ``None`` so direct ``compress()`` callers (e.g. + # tests, hand-written pipelines that don't go through the + # proxy) keep pre-F2.2 behaviour: TOIN writes are not gated. + # Same pattern the existing ``_runtime_target_ratio`` / + # ``_runtime_kompress_model`` fields below use. + self._runtime_compression_policy: Any = None + self._cache = CompressionCache() def _record_to_toin( @@ -687,6 +698,22 @@ class ContentRouter(Transform): if original_tokens <= compressed_tokens: return + # F2.2 gate: when the active CompressionPolicy says + # ``toin_read_only=True`` (Subscription auth mode), don't + # mutate the TOIN learning pool from this request. Direct + # ``compress()`` callers don't go through ``apply()`` and + # have ``self._runtime_compression_policy is None`` — those + # keep their pre-F2.2 write-enabled behaviour. + policy = self._runtime_compression_policy + if policy is not None and policy.toin_read_only: + logger.debug( + "ContentRouter: skipping TOIN record_compression for %s " + "— policy.toin_read_only=True (auth_mode resolved as " + "Subscription, F2.2 gate)", + strategy.value, + ) + return + try: # Lazy load TOIN if self._toin is None: @@ -1509,6 +1536,12 @@ class ContentRouter(Transform): # Store runtime options on self for access by _route_and_compress_block self._runtime_target_ratio: float | None = kwargs.get("target_ratio") self._runtime_kompress_model: str | None = kwargs.get("kompress_model") + # F2.2: capture the per-request CompressionPolicy so + # ``_record_to_toin`` can gate TOIN writes on + # ``policy.toin_read_only``. ``None`` when the caller didn't + # pass a policy — ``_record_to_toin`` treats that as "no gate" + # to preserve pre-F2.2 behaviour for non-proxy callers. + self._runtime_compression_policy = kwargs.get("compression_policy") tokens_before = sum(tokenizer.count_text(str(m.get("content", ""))) for m in messages) context = kwargs.get("context", "") diff --git a/headroom/transforms/smart_crusher.py b/headroom/transforms/smart_crusher.py index 1cdbd1df0..b0af28839 100644 --- a/headroom/transforms/smart_crusher.py +++ b/headroom/transforms/smart_crusher.py @@ -230,6 +230,18 @@ class SmartCrusher(Transform): self._toin: Any = None self._toin_load_failed = False + # F2.2: per-request CompressionPolicy, set from + # ``kwargs["compression_policy"]`` at the start of ``apply()`` + # and read by ``_record_to_toin`` to gate TOIN writes when + # ``policy.toin_read_only`` is true (Subscription mode). + # Defaults to ``None`` so the direct ``crush()`` / ``crush_array_json()`` + # / ``compact_document_json()`` entry points (which don't go + # through ``apply()``) keep their pre-F2.2 behaviour: TOIN + # writes are not gated. Same pattern as the existing + # ``_runtime_target_ratio`` / ``_runtime_kompress_model`` + # fields in ContentRouter. + self._runtime_compression_policy: Any = None + # Build the Rust crusher with every field from the Python # config, plus the relevance_threshold default (0.3) — the # Python dataclass doesn't carry that field; it lives on @@ -457,9 +469,29 @@ class SmartCrusher(Transform): implementation used. The router doesn't pass a tokenizer down this far, and re-tokenizing here would dominate the recording cost. Rough estimates are fine for learning aggregates. + + F2.2: when the active ``CompressionPolicy`` (set by + ``apply()`` from ``kwargs["compression_policy"]``) has + ``toin_read_only=True``, the write is skipped — Subscription + users keep prompt-cache stability AND don't mutate the global + TOIN learning pool from cache-sensitive traffic. Direct + ``crush()`` / ``crush_array_json()`` callers don't set the + policy, so they keep their pre-F2.2 write-enabled behaviour. """ if self._toin_load_failed: return + # F2.2 gate. Read the per-request policy set by ``apply()``; + # ``None`` means we are not running under the Transform + # protocol (direct caller via ``crush()``) and the legacy + # write-enabled behaviour applies. + policy = self._runtime_compression_policy + if policy is not None and policy.toin_read_only: + logger.debug( + "SmartCrusher: skipping TOIN record_compression — " + "policy.toin_read_only=True (auth_mode resolved as " + "Subscription, F2.2 gate)" + ) + return try: try: items = json.loads(original) @@ -782,6 +814,15 @@ class SmartCrusher(Transform): markers_inserted: list[str] = [] warnings: list[str] = [] + # F2.2: capture the per-request CompressionPolicy so + # ``_record_to_toin`` can gate TOIN writes on + # ``policy.toin_read_only``. Same one-liner pattern the + # ContentRouter uses for ``_runtime_target_ratio``. ``None`` + # when the caller didn't pass a policy (e.g. legacy direct- + # apply callers in tests) — ``_record_to_toin`` treats that + # as "no gate", matching pre-F2.2 behaviour. + self._runtime_compression_policy = kwargs.get("compression_policy") + query_context = self._extract_context_from_messages(result_messages) crushed_count = 0 frozen_message_count = kwargs.get("frozen_message_count", 0) diff --git a/tests/test_compression_policy_toin_gate.py b/tests/test_compression_policy_toin_gate.py new file mode 100644 index 000000000..6724fdc32 --- /dev/null +++ b/tests/test_compression_policy_toin_gate.py @@ -0,0 +1,289 @@ +"""F2.2: TOIN write-gate tests for the per-mode CompressionPolicy. + +When ``CompressionPolicy.toin_read_only`` is ``True`` (Subscription +auth mode), TOIN must serve cached recommendations but NEVER write new +pattern observations from this request. PAYG / OAuth keep writing so +the network effect keeps growing. The gate is read at the +``record_compression`` call site in ``smart_crusher.py`` and +``content_router.py``. + +These tests mirror the structure of +``tests/test_smart_crusher_toin_attachment.py`` (the F2.1-era TOIN +re-attachment regression suite) so a future contributor can locate the +expected behaviour by name. + +Behaviour matrix: + +| Mode | toin_read_only | record_compression called? | +|--------------|----------------|----------------------------| +| Payg | False | yes | +| OAuth | False | yes | +| Subscription | True | NO | + +Direct callers (those that call ``crush()`` / ``crush_array_json()`` +without going through ``apply()``) don't set +``self._runtime_compression_policy``, so they keep their pre-F2.2 +write-enabled behaviour. That's a deliberate compatibility decision — +non-proxy callers have no auth context. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +from headroom.proxy.auth_mode import AuthMode +from headroom.telemetry.toin import TOINConfig, get_toin, reset_toin +from headroom.tokenizer import Tokenizer +from headroom.tokenizers import EstimatingTokenCounter +from headroom.transforms.compression_policy import policy_for_mode + + +def _has_core() -> bool: + """Match the pattern in ``test_smart_crusher_rust_parity.py``. + + SmartCrusher's __init__ hard-imports ``headroom._core`` (the Rust + PyO3 wheel). On dev machines or CI lanes that haven't run + ``scripts/build_rust_extension.sh``, the wheel is absent. Skip the + SmartCrusher-touching tests rather than fail loudly — the + ContentRouter tests don't need the wheel and exercise the same + F2.2 gate code path. + """ + try: + from headroom._core import SmartCrusher # noqa: F401 + + return True + except ImportError: + return False + + +_skip_no_core = pytest.mark.skipif( + not _has_core(), + reason="headroom._core wheel not installed (run `scripts/build_rust_extension.sh`)", +) + + +@pytest.fixture +def fresh_toin(): + """Per-test TOIN instance backed by a tempdir to avoid global drift.""" + reset_toin() + with tempfile.TemporaryDirectory() as tmpdir: + storage = str(Path(tmpdir) / "toin.json") + toin = get_toin( + TOINConfig( + storage_path=storage, + auto_save_interval=0, + ) + ) + yield toin + reset_toin() + + +def _bigger_array(n: int = 60) -> str: + """JSON array of `n` dicts, sized to trigger crushing. + + Mirrors the helper in ``test_smart_crusher_toin_attachment.py`` so + these tests use the same shape and any "didn't trigger compression" + skip lines up with the existing suite. + """ + items = [{"status": "ok", "tag": "x", "n": i} for i in range(n)] + return json.dumps(items) + + +def _wrap_in_tool_message(payload: str) -> list[dict]: + """Build the OpenAI-style ``role=tool`` message ``apply()`` walks.""" + return [{"role": "tool", "content": payload, "tool_call_id": "t1"}] + + +def _tokenizer() -> Tokenizer: + return Tokenizer(EstimatingTokenCounter()) # type: ignore[arg-type] + + +# ─── SmartCrusher: apply() with policy ────────────────────────────────── + + +@_skip_no_core +def test_smart_crusher_payg_policy_writes_to_toin(fresh_toin): + """PAYG: ``toin_read_only=False`` → record_compression IS called.""" + from headroom.transforms.smart_crusher import SmartCrusher, SmartCrusherConfig + + crusher = SmartCrusher(SmartCrusherConfig()) + messages = _wrap_in_tool_message(_bigger_array(60)) + pre = sum(p.total_compressions for p in fresh_toin._patterns.values()) + + policy = policy_for_mode(AuthMode.PAYG) + assert policy.toin_read_only is False # baseline sanity + result = crusher.apply(messages, _tokenizer(), compression_policy=policy) + + if not result.transforms_applied: + pytest.skip("payload didn't trigger compression — bump the size") + post = sum(p.total_compressions for p in fresh_toin._patterns.values()) + assert post > pre, "PAYG should write to TOIN (network effect)" + + +@_skip_no_core +def test_smart_crusher_oauth_policy_writes_to_toin(fresh_toin): + """OAuth: identical to PAYG in F2.2 — writes enabled.""" + from headroom.transforms.smart_crusher import SmartCrusher, SmartCrusherConfig + + crusher = SmartCrusher(SmartCrusherConfig()) + messages = _wrap_in_tool_message(_bigger_array(60)) + pre = sum(p.total_compressions for p in fresh_toin._patterns.values()) + + policy = policy_for_mode(AuthMode.OAUTH) + assert policy.toin_read_only is False + result = crusher.apply(messages, _tokenizer(), compression_policy=policy) + + if not result.transforms_applied: + pytest.skip("payload didn't trigger compression — bump the size") + post = sum(p.total_compressions for p in fresh_toin._patterns.values()) + assert post > pre, "OAuth (matches PAYG today) should write to TOIN" + + +@_skip_no_core +def test_smart_crusher_subscription_policy_skips_toin_write(fresh_toin): + """Subscription: ``toin_read_only=True`` → record_compression is NOT called. + + This is THE behaviour change of F2.2 — keep the learning pool + consistent for cache-stability-sensitive traffic. + """ + from headroom.transforms.smart_crusher import SmartCrusher, SmartCrusherConfig + + crusher = SmartCrusher(SmartCrusherConfig()) + messages = _wrap_in_tool_message(_bigger_array(60)) + pre = sum(p.total_compressions for p in fresh_toin._patterns.values()) + + policy = policy_for_mode(AuthMode.SUBSCRIPTION) + assert policy.toin_read_only is True # baseline sanity + result = crusher.apply(messages, _tokenizer(), compression_policy=policy) + + # Compression itself should still complete — this gate is on the + # learning side only, not the compression path. + if not result.transforms_applied: + pytest.skip("payload didn't trigger compression — bump the size") + post = sum(p.total_compressions for p in fresh_toin._patterns.values()) + assert post == pre, ( + "Subscription MUST NOT write to TOIN — load-bearing for keeping " + "the learning pool consistent across cache-sensitive traffic" + ) + + +@_skip_no_core +def test_smart_crusher_no_policy_keeps_legacy_write_behaviour(fresh_toin): + """Direct ``apply()`` call without ``compression_policy`` keeps + pre-F2.2 behaviour: TOIN writes are not gated. + + Many test fixtures and non-proxy callers don't pass a policy; they + must continue to feed the learning pool exactly as they did + before F2.2. + """ + from headroom.transforms.smart_crusher import SmartCrusher, SmartCrusherConfig + + crusher = SmartCrusher(SmartCrusherConfig()) + messages = _wrap_in_tool_message(_bigger_array(60)) + pre = sum(p.total_compressions for p in fresh_toin._patterns.values()) + + # No `compression_policy` kwarg. + result = crusher.apply(messages, _tokenizer()) + + if not result.transforms_applied: + pytest.skip("payload didn't trigger compression — bump the size") + post = sum(p.total_compressions for p in fresh_toin._patterns.values()) + assert post > pre, "no policy → legacy write-enabled behaviour" + + +# ─── ContentRouter: apply() captures the policy ───────────────────────── + + +def test_content_router_apply_stores_runtime_policy(): + """``ContentRouter.apply()`` must populate + ``self._runtime_compression_policy`` from kwargs so + ``_record_to_toin`` can read it. + + We don't assert TOIN behaviour here (the router routes most JSON + arrays to SmartCrusher, which has its own gate already covered + above); the load-bearing thing for the parity guard is that the + field is wired through. + """ + from headroom.transforms.content_router import ContentRouter + + router = ContentRouter() + # Sanity: the field exists on a fresh instance and starts None. + assert router._runtime_compression_policy is None + + policy = policy_for_mode(AuthMode.SUBSCRIPTION) + # Empty-message apply is fine — the field assignment happens + # before the message walk, so we don't need a payload that + # actually compresses. + router.apply([], _tokenizer(), compression_policy=policy) + assert router._runtime_compression_policy is policy, ( + "ContentRouter.apply() must capture the policy onto self so _record_to_toin can read it" + ) + + +def test_content_router_subscription_skips_toin_record(fresh_toin): + """ContentRouter._record_to_toin returns early when + policy.toin_read_only is True. + + We exercise the gate directly rather than building a fixture that + routes to a non-SmartCrusher compressor — both are equivalent + coverage for the gate, and the direct call avoids the routing + flake from ``test_smart_crusher_toin_attachment.py``'s comments. + """ + from headroom.transforms.content_router import ( + CompressionStrategy, + ContentRouter, + ) + + router = ContentRouter() + router._runtime_compression_policy = policy_for_mode(AuthMode.SUBSCRIPTION) + + pre = sum(p.total_compressions for p in fresh_toin._patterns.values()) + # Pick TEXT strategy (not SMART_CRUSHER, which has its own + # early-return). With Subscription policy, the F2.2 gate fires + # and the call returns before ever loading TOIN. + router._record_to_toin( + strategy=CompressionStrategy.TEXT, + content="some text content", + compressed="compressed", + original_tokens=100, + compressed_tokens=50, + ) + post = sum(p.total_compressions for p in fresh_toin._patterns.values()) + assert post == pre, "Subscription policy must skip ContentRouter TOIN write" + + +def test_content_router_payg_records_to_toin(fresh_toin): + """PAYG policy → ContentRouter._record_to_toin proceeds to the + real TOIN call. Asserts the gate doesn't accidentally fire when + ``toin_read_only=False``. + """ + from headroom.transforms.content_router import ( + CompressionStrategy, + ContentRouter, + ) + + router = ContentRouter() + router._runtime_compression_policy = policy_for_mode(AuthMode.PAYG) + + pre = sum(p.total_compressions for p in fresh_toin._patterns.values()) + router._record_to_toin( + strategy=CompressionStrategy.TEXT, + content="some text content with structure that learns", + compressed="compressed shorter", + original_tokens=100, + compressed_tokens=50, + ) + post = sum(p.total_compressions for p in fresh_toin._patterns.values()) + # Real TOIN write should happen unless _create_content_signature + # returns None (it can for malformed inputs). We accept either + # "post > pre" (signature succeeded) OR "post == pre with a + # signature-None path"; the load-bearing assertion is that the + # F2.2 gate did NOT fire (which it would with toin_read_only=True + # regardless of signature). + assert post >= pre, ( + "PAYG must not be blocked by the F2.2 gate — write should happen or fall through naturally" + ) From be5d9f7baaf11d706afecf7c0ca0b9da984f38a2 Mon Sep 17 00:00:00 2001 From: chopratejas Date: Wed, 6 May 2026 14:39:39 -0700 Subject: [PATCH 3/3] fix(tests): update CacheAligner detector-only tests for F2.2 5-field CompressionPolicy The two F2.1 fixtures constructed CompressionPolicy with only 2 fields; F2.2 added three required tuning fields (volatile_token_threshold, max_lossy_ratio, toin_read_only). Updated subscription-disabled fixture to use Subscription defaults (32 / 0.25 / True) and the PAYG-enabled fixture to use PAYG defaults (128 / 0.45 / False), mirroring policy_for_mode(). Prefer the fixture update over default-valued fields in the dataclass so the F2.2 parity-test invariant (every CompressionPolicy is fully specified per-mode) stays load-bearing. --- tests/test_cache_aligner_detector_only.py | 29 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/test_cache_aligner_detector_only.py b/tests/test_cache_aligner_detector_only.py index 8579797fd..5d8a25c6c 100644 --- a/tests/test_cache_aligner_detector_only.py +++ b/tests/test_cache_aligner_detector_only.py @@ -205,6 +205,12 @@ def test_should_apply_false_when_policy_disables_aligner(tokenizer: Tokenizer) - ``self._previous_prefix_hash`` and emitting volatility warnings, which are the exact log lines #327/#388 reporters complained about. + + F2.2 added three per-mode tuning fields to CompressionPolicy + (``volatile_token_threshold``, ``max_lossy_ratio``, + ``toin_read_only``); the policy here uses the Subscription + defaults from ``policy_for_mode(AuthMode.SUBSCRIPTION)`` so the + fixture mirrors a real subscription request. """ from headroom.transforms.compression_policy import CompressionPolicy @@ -213,18 +219,35 @@ def test_should_apply_false_when_policy_disables_aligner(tokenizer: Tokenizer) - # Sanity: without a policy, the detector opts in. assert aligner.should_apply(messages, tokenizer) # F2.1 gate: with the subscription policy, the detector opts out. - sub_policy = CompressionPolicy(live_zone_only=True, cache_aligner_enabled=False) + sub_policy = CompressionPolicy( + live_zone_only=True, + cache_aligner_enabled=False, + volatile_token_threshold=32, + max_lossy_ratio=0.25, + toin_read_only=True, + ) assert not aligner.should_apply(messages, tokenizer, compression_policy=sub_policy) def test_should_apply_true_when_policy_enables_aligner(tokenizer: Tokenizer) -> None: """F2.1 c4/5: ``compression_policy.cache_aligner_enabled=True`` - must NOT short-circuit. PAYG/OAuth keep current behaviour.""" + must NOT short-circuit. PAYG/OAuth keep current behaviour. + + F2.2 added three per-mode tuning fields; the policy here uses the + PAYG defaults from ``policy_for_mode(AuthMode.PAYG)`` so the + fixture mirrors a real PAYG request. + """ from headroom.transforms.compression_policy import CompressionPolicy messages = _system_user_messages("Session: 550e8400-e29b-41d4-a716-446655440000") aligner = CacheAligner(CacheAlignerConfig(enabled=True)) - payg_policy = CompressionPolicy(live_zone_only=False, cache_aligner_enabled=True) + payg_policy = CompressionPolicy( + live_zone_only=False, + cache_aligner_enabled=True, + volatile_token_threshold=128, + max_lossy_ratio=0.45, + toin_read_only=False, + ) assert aligner.should_apply(messages, tokenizer, compression_policy=payg_policy)