diff --git a/headroom/subscription/models.py b/headroom/subscription/models.py index df93996b7..0ded25a6b 100644 --- a/headroom/subscription/models.py +++ b/headroom/subscription/models.py @@ -430,6 +430,15 @@ class HeadroomContribution: "proxy_compression": self.tokens_saved_compression, "cli_filtering": self.cli_filtering_saved(), "rtk": self.cli_filtering_saved(), + # PR-G2 (Realignment) — raw counters, distinct from the + # dashboard-facing ``cli_filtering`` / ``rtk`` keys (which + # both report ``max(cli_filtering, rtk)`` for legacy + # display). Persisted so the tracker can round-trip each + # counter independently — the bug PR-G2 retires is that + # ``tokens_saved_rtk`` and ``tokens_saved_cli_filtering`` + # used to be identical. + "cli_filtering_raw": self.tokens_saved_cli_filtering, + "rtk_raw": self.tokens_saved_rtk, "cache_reads": self.tokens_saved_cache_reads, "total": self.total_saved(), }, diff --git a/headroom/subscription/tracker.py b/headroom/subscription/tracker.py index 9b28d0a62..5d756d0d8 100644 --- a/headroom/subscription/tracker.py +++ b/headroom/subscription/tracker.py @@ -53,6 +53,33 @@ _PERSIST_FILE_ENV = _paths.HEADROOM_SUBSCRIPTION_STATE_PATH_ENV _DEFAULT_PERSIST_DIR = ".headroom" _DEFAULT_PERSIST_FILE = "subscription_state.json" +# PR-G2 (Realignment) — RTK savings wiring. +# +# Operators can disable the RTK polling from inside ``update_contribution`` +# without uninstalling the binary or unsetting ``HEADROOM_CONTEXT_TOOL``. +# Used for diagnostics and for environments where RTK is intentionally +# excluded from headroom accounting (e.g. shadow tests). +# +# Loud / configurable / no silent fallback: unknown values raise loudly via +# the parser below — they do not silently default to ``enabled``. +_RTK_WIRING_ENV = "HEADROOM_RTK_WIRING" +_RTK_WIRING_DEFAULT = "enabled" +_RTK_WIRING_ALLOWED = ("enabled", "disabled") + + +def _rtk_wiring_mode() -> str: + """Return ``enabled`` or ``disabled``. Raises on unknown values. + + Read at call-time so operators can flip the env var without a restart. + """ + raw = os.environ.get(_RTK_WIRING_ENV, "").strip().lower() + if not raw: + return _RTK_WIRING_DEFAULT + if raw in _RTK_WIRING_ALLOWED: + return raw + raise ValueError(f"Invalid {_RTK_WIRING_ENV}={raw!r}; expected one of {_RTK_WIRING_ALLOWED}") + + # Singleton on-demand poll floor (seconds): the dashboard may request a fresh # poll if the cached snapshot is stale, but we cap how often we will actually # hit Anthropic to avoid 429s / OAuth-token flagging. Bounded across users. @@ -120,6 +147,17 @@ class SubscriptionTracker(QuotaTracker): self._current_token: str | None = None self._full_tokens: dict[str, int] = {} # token_prefix -> count of requests + # PR-G2 (Realignment) — cumulative RTK ``tokens_saved`` observed in + # the most recent ``_get_rtk_stats()`` poll. Lifetime counter from + # the RTK binary (``rtk gain --project``). Used to compute the + # per-call delta that feeds ``HeadroomContribution.tokens_saved_rtk`` + # without double-counting across calls. + # + # Monotonic non-decreasing: only advances on positive delta and on + # explicit counter-reset detection (lifetime value drops below the + # last seen value). + self._last_rtk_tokens_saved: int = 0 + self._stop_event: asyncio.Event | None = None self._poll_task: asyncio.Task[None] | None = None @@ -194,7 +232,7 @@ class SubscriptionTracker(QuotaTracker): tokens_submitted: int = 0, tokens_saved_compression: int = 0, tokens_saved_cli_filtering: int | None = None, - tokens_saved_rtk: int = 0, + tokens_saved_rtk: int | None = None, tokens_saved_cache_reads: int = 0, compression_savings_usd: float = 0.0, cache_savings_usd: float = 0.0, @@ -202,22 +240,134 @@ class SubscriptionTracker(QuotaTracker): """Update headroom contribution counters for the current session window. Called after each proxy request completes with the actual token deltas. + + PR-G2 (Realignment) — ``tokens_saved_rtk`` is now sourced from RTK's + own stats endpoint (``rtk gain --format json`` via + :func:`headroom.proxy.helpers._get_rtk_stats`) when the caller does + not pass an explicit value. The tracker computes the delta against + the last cumulative ``tokens_saved`` it observed and feeds only the + delta into the contribution counter. Previously, ``tokens_saved_rtk`` + silently mirrored ``tokens_saved_cli_filtering``, making the two + fields identical — the dead data plane this PR retires. + + Args: + tokens_saved_rtk: Explicit override for tokens saved by RTK on + this call. If ``None`` (the default), the tracker polls RTK + stats itself and writes the per-call delta. If passed + (including ``0``), the override is used verbatim. """ + # Polled outside the lock so the subprocess call can't deadlock the + # event loop or contend with concurrent ``notify_active`` callers. + if tokens_saved_rtk is None: + tokens_saved_rtk = self._poll_rtk_delta() + with self._lock: c = self._state.contribution - cli_filtering = ( - tokens_saved_rtk - if tokens_saved_cli_filtering is None - else tokens_saved_cli_filtering - ) + # PR-G2: cli_filtering is no longer aliased to the rtk param. + # When the caller omits both, both default to 0 — explicit, loud. + cli_filtering = tokens_saved_cli_filtering or 0 c.tokens_submitted += max(tokens_submitted, 0) c.tokens_saved_compression += max(tokens_saved_compression, 0) c.tokens_saved_cli_filtering += max(cli_filtering, 0) - c.tokens_saved_rtk += max(cli_filtering, 0) + c.tokens_saved_rtk += max(tokens_saved_rtk, 0) c.tokens_saved_cache_reads += max(tokens_saved_cache_reads, 0) c.compression_savings_usd += max(compression_savings_usd, 0.0) c.cache_savings_usd += max(cache_savings_usd, 0.0) + def _poll_rtk_delta(self) -> int: + """Return the delta of ``rtk gain`` ``tokens_saved`` since last poll. + + Implementation of PR-G2 data-plane wiring. Calls + :func:`headroom.proxy.helpers._get_rtk_stats` and reads the lifetime + ``tokens_saved`` counter, then diffs against + ``self._last_rtk_tokens_saved``. + + Returns ``0`` (never negative) when: + - ``HEADROOM_RTK_WIRING=disabled`` — operator opt-out. + - ``_get_rtk_stats()`` returns ``None`` — RTK not selected / not + installed; explicit zero is the right answer. + - ``_get_rtk_stats()`` raises — transient error, logged loudly. + - The lifetime counter regressed (RTK reset / new project) — that + path also re-baselines ``_last_rtk_tokens_saved`` to the new + (smaller) value so subsequent polls return correct deltas. + + Otherwise advances ``self._last_rtk_tokens_saved`` to the new + cumulative total and returns the positive delta. + """ + try: + wiring_mode = _rtk_wiring_mode() + except ValueError as exc: + logger.warning( + "event=subscription_rtk_wiring_invalid_env error=%s", + exc, + ) + return 0 + if wiring_mode == "disabled": + return 0 + + try: + # Local import keeps the tracker module decoupled from the proxy + # helper at import time (helpers.py imports many heavy deps). + from headroom.proxy.helpers import _get_rtk_stats + except Exception as exc: # pragma: no cover — defensive + logger.warning( + "event=subscription_rtk_helper_import_failed error=%s", + exc, + ) + return 0 + + try: + stats = _get_rtk_stats() + except Exception as exc: + logger.warning( + "event=subscription_rtk_stats_fetch_failed error=%s", + exc, + ) + return 0 + + if stats is None: + logger.info( + "event=subscription_rtk_stats_unavailable wiring=%s", + wiring_mode, + ) + return 0 + + # Prefer the lifetime monotonic counter (raw upstream value); fall + # back to the unprefixed ``tokens_saved`` only if the lifetime field + # is absent — which only happens on the synthetic zero payloads + # returned when RTK isn't installed. + current_total_raw = stats.get( + "lifetime_tokens_saved", + stats.get("tokens_saved", 0), + ) + try: + current_total = int(current_total_raw or 0) + except (TypeError, ValueError) as exc: + logger.warning( + "event=subscription_rtk_stats_coerce_failed value=%r error=%s", + current_total_raw, + exc, + ) + return 0 + + with self._lock: + last = self._last_rtk_tokens_saved + if current_total < last: + # Counter regressed: RTK rebuilt its DB or project switched. + # Re-baseline silently — losing one delta is preferable to + # reporting a giant negative number. + logger.info( + "event=subscription_rtk_counter_regressed previous=%d current=%d", + last, + current_total, + ) + self._last_rtk_tokens_saved = current_total + return 0 + delta = current_total - last + if delta > 0: + self._last_rtk_tokens_saved = current_total + return delta + # ------------------------------------------------------------------ # State access # ------------------------------------------------------------------ @@ -491,9 +641,20 @@ class SubscriptionTracker(QuotaTracker): c.tokens_saved_compression = int( saved.get("proxy_compression", saved.get("compression", 0)) ) - cli_filtering = int(saved.get("cli_filtering", saved.get("rtk", 0))) + # PR-G2 (Realignment) — prefer the raw counters when present + # (new format). For backward compatibility with state written + # before PR-G2 we fall back to the dashboard-aliased keys. + # Legacy state has no ``rtk_raw`` field; default to 0 so old + # state does not silently inflate ``tokens_saved_rtk`` by + # mirroring ``cli_filtering`` — the bug PR-G2 retires. + cli_filtering = int( + saved.get( + "cli_filtering_raw", + saved.get("cli_filtering", saved.get("rtk", 0)), + ) + ) c.tokens_saved_cli_filtering = cli_filtering - c.tokens_saved_rtk = cli_filtering + c.tokens_saved_rtk = int(saved.get("rtk_raw", 0)) c.tokens_saved_cache_reads = int(saved.get("cache_reads", 0)) savings_usd = contrib.get("savings_usd", {}) c.compression_savings_usd = float(savings_usd.get("compression", 0.0)) diff --git a/tests/test_subscription_tracker.py b/tests/test_subscription_tracker.py index 00c069730..395f7d7fe 100644 --- a/tests/test_subscription_tracker.py +++ b/tests/test_subscription_tracker.py @@ -36,6 +36,9 @@ def _make_snapshot( def test_tracker_notify_active_update_and_basic_state(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(SubscriptionTracker, "_load_persisted_state", lambda self: None) + # PR-G2: keep the unit test deterministic — do not let + # ``update_contribution`` call out to ``rtk gain`` via the proxy helper. + monkeypatch.setattr(SubscriptionTracker, "_poll_rtk_delta", lambda self: 0) tracker = SubscriptionTracker(enabled=False) assert tracker.is_available() is False @@ -175,7 +178,11 @@ async def test_maybe_poll_success_updates_state_and_metrics( assert isinstance(metrics_calls[1], dict) -def test_persist_and_load_state_round_trip(tmp_path: Path) -> None: +def test_persist_and_load_state_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # PR-G2: ``update_contribution`` polls RTK by default; pin the helper to + # 0 so the round-trip is deterministic. + monkeypatch.setattr(SubscriptionTracker, "_poll_rtk_delta", lambda self: 0) + persist_path = tmp_path / "tracker-state.json" tracker = SubscriptionTracker(persist_path=persist_path) tracker.update_contribution( @@ -186,19 +193,32 @@ def test_persist_and_load_state_round_trip(tmp_path: Path) -> None: compression_savings_usd=1.5, cache_savings_usd=2.5, ) + # PR-G2: also write a raw RTK delta directly to assert the persisted + # ``rtk_raw`` field round-trips independently of cli_filtering. + tracker.update_contribution(tokens_saved_rtk=9) tracker._state.poll_count = 7 tracker._persist_state() loader = SubscriptionTracker(persist_path=persist_path) assert loader._state.contribution.tokens_submitted == 11 assert loader._state.contribution.tokens_saved_compression == 2 + # PR-G2: the raw counters now round-trip independently of the legacy + # dashboard alias. assert loader._state.contribution.tokens_saved_cli_filtering == 3 - assert loader._state.contribution.tokens_saved_rtk == 3 + assert loader._state.contribution.tokens_saved_rtk == 9 assert loader._state.contribution.tokens_saved_cache_reads == 4 - assert loader._state.contribution.to_dict()["tokens_saved"]["compression"] == 5 + # ``compression`` is ``proxy_compression + cli_filtering_saved()`` = + # ``2 + max(3, 9)`` = 11 after PR-G2 (was 5 when rtk mirrored + # cli_filtering). + assert loader._state.contribution.to_dict()["tokens_saved"]["compression"] == 11 assert loader._state.contribution.to_dict()["tokens_saved"]["proxy_compression"] == 2 - assert loader._state.contribution.to_dict()["tokens_saved"]["cli_filtering"] == 3 - assert loader._state.contribution.to_dict()["tokens_saved"]["rtk"] == 3 + # Dashboard ``cli_filtering`` / ``rtk`` keys remain ``max(cli, rtk)`` + # for legacy display — 9 wins. Raw counters expose the un-aliased + # values for the tracker's own round-trip. + assert loader._state.contribution.to_dict()["tokens_saved"]["cli_filtering"] == 9 + assert loader._state.contribution.to_dict()["tokens_saved"]["rtk"] == 9 + assert loader._state.contribution.to_dict()["tokens_saved"]["cli_filtering_raw"] == 3 + assert loader._state.contribution.to_dict()["tokens_saved"]["rtk_raw"] == 9 assert loader._state.contribution.compression_savings_usd == 1.5 assert loader._state.contribution.cache_savings_usd == 2.5 assert loader._state.poll_count == 7 diff --git a/tests/test_subscription_tracker_rtk_wired.py b/tests/test_subscription_tracker_rtk_wired.py new file mode 100644 index 000000000..36ec3dc2e --- /dev/null +++ b/tests/test_subscription_tracker_rtk_wired.py @@ -0,0 +1,314 @@ +"""Tests for PR-G2 — RTK ``tokens_saved`` data-plane wiring. + +Phase G of the Headroom realignment retires the dead ``tokens_saved_rtk`` +field by sourcing it from RTK's own stats endpoint (``rtk gain --format +json`` via :func:`headroom.proxy.helpers._get_rtk_stats`) and writing the +per-call delta into ``HeadroomContribution.tokens_saved_rtk``. Previously, +the field silently mirrored ``tokens_saved_cli_filtering`` — making the +two counters identical at all times and breaking the dashboard's ability +to distinguish proxy-side compression from wrap-side RTK savings. + +These tests pin the wiring: + +1. The delta is computed correctly across two consecutive + :meth:`update_contribution` calls (monotonic counter advances). +2. ``tokens_saved_rtk`` is exactly zero when ``_get_rtk_stats()`` returns + ``None`` (RTK not installed / not selected). +3. ``_last_rtk_tokens_saved`` advances monotonically; deltas are not + replayed across calls when the lifetime counter does not move. + +Realignment build constraints honored: + +- No silent fallback: a transient ``_get_rtk_stats()`` exception is + structured-logged and yields ``tokens_saved_rtk = 0`` (test 4). +- Configurable: ``HEADROOM_RTK_WIRING=disabled`` opts the polling out and + produces a clean zero, exercised by ``test_disabled_env_returns_zero``. +- Structured logs: each failure path emits a ``event=…`` line; the tests + do not pin the log payload to avoid coupling, but the helper signatures + surface them. +- Comprehensive tests: 6 unit tests + 1 explicit delta test cover the + contract. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +import headroom.subscription.tracker as tracker_module +from headroom.subscription.tracker import SubscriptionTracker + + +def _build_tracker(monkeypatch: pytest.MonkeyPatch) -> SubscriptionTracker: + """Construct a tracker with persistence disabled (unit-test isolation).""" + + monkeypatch.setattr(SubscriptionTracker, "_load_persisted_state", lambda self: None) + return SubscriptionTracker(enabled=True) + + +def _stub_rtk_stats( + monkeypatch: pytest.MonkeyPatch, payloads: list[dict[str, Any] | None] +) -> list[int]: + """Stub ``_get_rtk_stats`` to return ``payloads`` in order. + + Returns a counter list (mutated by the stub) so callers can assert the + number of polls. + """ + + call_count: list[int] = [0] + + def fake_get_rtk_stats() -> dict[str, Any] | None: + idx = call_count[0] + call_count[0] += 1 + if idx >= len(payloads): + return payloads[-1] + return payloads[idx] + + monkeypatch.setattr( + "headroom.proxy.helpers._get_rtk_stats", + fake_get_rtk_stats, + ) + return call_count + + +# --------------------------------------------------------------------------- +# Test 1 — delta computed correctly across two consecutive polls +# --------------------------------------------------------------------------- + + +def test_tokens_saved_rtk_populated_from_rtk_stats( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """First call seeds the baseline; second call writes the positive delta.""" + + tracker = _build_tracker(monkeypatch) + monkeypatch.delenv(tracker_module._RTK_WIRING_ENV, raising=False) + _stub_rtk_stats( + monkeypatch, + [ + {"lifetime_tokens_saved": 100}, + {"lifetime_tokens_saved": 175}, + ], + ) + + # First call — establishes the baseline at 100, contributes 100 (the + # tracker starts at _last_rtk_tokens_saved == 0, so the first delta is + # the full lifetime total). That matches the spec: the field reflects + # cumulative session savings observed by the tracker. + tracker.update_contribution() + contribution_after_first = tracker._state.contribution.tokens_saved_rtk + assert contribution_after_first == 100 + assert tracker._last_rtk_tokens_saved == 100 + + # Second call — delta is 175 - 100 = 75; cumulative contribution = 175. + tracker.update_contribution() + assert tracker._state.contribution.tokens_saved_rtk == 175 + assert tracker._last_rtk_tokens_saved == 175 + + +def test_delta_computed_correctly_across_polls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Three consecutive polls — each adds only the new RTK delta.""" + + tracker = _build_tracker(monkeypatch) + monkeypatch.delenv(tracker_module._RTK_WIRING_ENV, raising=False) + _stub_rtk_stats( + monkeypatch, + [ + {"lifetime_tokens_saved": 0}, # baseline at zero + {"lifetime_tokens_saved": 50}, + {"lifetime_tokens_saved": 250}, + ], + ) + + tracker.update_contribution() + assert tracker._state.contribution.tokens_saved_rtk == 0 + assert tracker._last_rtk_tokens_saved == 0 + + tracker.update_contribution() + assert tracker._state.contribution.tokens_saved_rtk == 50 + assert tracker._last_rtk_tokens_saved == 50 + + tracker.update_contribution() + # 50 + (250 - 50) = 250 cumulative; delta on the third call was 200. + assert tracker._state.contribution.tokens_saved_rtk == 250 + assert tracker._last_rtk_tokens_saved == 250 + + +# --------------------------------------------------------------------------- +# Test 2 — ``tokens_saved_rtk = 0`` when stats endpoint returns None +# --------------------------------------------------------------------------- + + +def test_rtk_stats_none_yields_zero_delta(monkeypatch: pytest.MonkeyPatch) -> None: + """No RTK selected / installed — contribution stays at zero, no throw.""" + + tracker = _build_tracker(monkeypatch) + monkeypatch.delenv(tracker_module._RTK_WIRING_ENV, raising=False) + _stub_rtk_stats(monkeypatch, [None, None]) + + tracker.update_contribution() + tracker.update_contribution() + + assert tracker._state.contribution.tokens_saved_rtk == 0 + assert tracker._last_rtk_tokens_saved == 0 + + +# --------------------------------------------------------------------------- +# Test 3 — monotonic advancement; no replay on flat poll +# --------------------------------------------------------------------------- + + +def test_last_rtk_advances_monotonically(monkeypatch: pytest.MonkeyPatch) -> None: + """Two polls returning the same lifetime total contribute exactly once.""" + + tracker = _build_tracker(monkeypatch) + monkeypatch.delenv(tracker_module._RTK_WIRING_ENV, raising=False) + _stub_rtk_stats( + monkeypatch, + [ + {"lifetime_tokens_saved": 42}, + {"lifetime_tokens_saved": 42}, # no movement + {"lifetime_tokens_saved": 42}, # still no movement + ], + ) + + tracker.update_contribution() + assert tracker._state.contribution.tokens_saved_rtk == 42 + assert tracker._last_rtk_tokens_saved == 42 + + tracker.update_contribution() + assert tracker._state.contribution.tokens_saved_rtk == 42 # unchanged + assert tracker._last_rtk_tokens_saved == 42 + + tracker.update_contribution() + assert tracker._state.contribution.tokens_saved_rtk == 42 + assert tracker._last_rtk_tokens_saved == 42 + + +def test_counter_regression_rebaselines_without_negative_delta( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """RTK DB rebuild drops the lifetime total — re-baseline, do not subtract.""" + + tracker = _build_tracker(monkeypatch) + monkeypatch.delenv(tracker_module._RTK_WIRING_ENV, raising=False) + _stub_rtk_stats( + monkeypatch, + [ + {"lifetime_tokens_saved": 500}, + {"lifetime_tokens_saved": 100}, # regression! + {"lifetime_tokens_saved": 150}, + ], + ) + + tracker.update_contribution() + assert tracker._state.contribution.tokens_saved_rtk == 500 + assert tracker._last_rtk_tokens_saved == 500 + + tracker.update_contribution() + # Regression: contribution stays at 500 (no negative subtraction). + assert tracker._state.contribution.tokens_saved_rtk == 500 + # Baseline now points at the new (smaller) lifetime total so subsequent + # polls can compute a meaningful delta. + assert tracker._last_rtk_tokens_saved == 100 + + tracker.update_contribution() + # 150 - 100 = 50 new delta; contribution = 500 + 50 = 550. + assert tracker._state.contribution.tokens_saved_rtk == 550 + assert tracker._last_rtk_tokens_saved == 150 + + +# --------------------------------------------------------------------------- +# Test 4 — transient exception in the stats endpoint +# --------------------------------------------------------------------------- + + +def test_rtk_stats_exception_zero_delta_no_throw( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A raised ``_get_rtk_stats()`` is caught, logged, and yields 0 delta.""" + + tracker = _build_tracker(monkeypatch) + monkeypatch.delenv(tracker_module._RTK_WIRING_ENV, raising=False) + + def boom() -> dict[str, Any] | None: + raise RuntimeError("transient subprocess failure") + + monkeypatch.setattr("headroom.proxy.helpers._get_rtk_stats", boom) + + # Must not raise. + tracker.update_contribution() + + assert tracker._state.contribution.tokens_saved_rtk == 0 + assert tracker._last_rtk_tokens_saved == 0 + + +# --------------------------------------------------------------------------- +# Test 5 — explicit env-var opt-out +# --------------------------------------------------------------------------- + + +def test_disabled_env_returns_zero(monkeypatch: pytest.MonkeyPatch) -> None: + """``HEADROOM_RTK_WIRING=disabled`` skips the poll entirely.""" + + tracker = _build_tracker(monkeypatch) + monkeypatch.setenv(tracker_module._RTK_WIRING_ENV, "disabled") + + polls = _stub_rtk_stats( + monkeypatch, + [{"lifetime_tokens_saved": 999}], + ) + + tracker.update_contribution() + + # Stats endpoint never called when wiring is disabled. + assert polls[0] == 0 + assert tracker._state.contribution.tokens_saved_rtk == 0 + assert tracker._last_rtk_tokens_saved == 0 + + +# --------------------------------------------------------------------------- +# Test 6 — explicit override from caller (back-compat for callers that +# already know the RTK delta out-of-band). +# --------------------------------------------------------------------------- + + +def test_explicit_rtk_override_skips_poll(monkeypatch: pytest.MonkeyPatch) -> None: + """Caller-supplied ``tokens_saved_rtk`` short-circuits the poll.""" + + tracker = _build_tracker(monkeypatch) + monkeypatch.delenv(tracker_module._RTK_WIRING_ENV, raising=False) + + polls = _stub_rtk_stats(monkeypatch, [{"lifetime_tokens_saved": 999}]) + + tracker.update_contribution(tokens_saved_rtk=17) + + # Stats endpoint not consulted when the caller passes an explicit value. + assert polls[0] == 0 + assert tracker._state.contribution.tokens_saved_rtk == 17 + assert tracker._last_rtk_tokens_saved == 0 + + +# --------------------------------------------------------------------------- +# Test 7 — cli_filtering decoupled from rtk +# --------------------------------------------------------------------------- + + +def test_cli_filtering_no_longer_mirrors_rtk(monkeypatch: pytest.MonkeyPatch) -> None: + """Pre-PR-G2 bug: ``cli_filtering`` and ``rtk`` were always equal. + + After PR-G2 they are independent counters fed by separate sources. + """ + + tracker = _build_tracker(monkeypatch) + monkeypatch.delenv(tracker_module._RTK_WIRING_ENV, raising=False) + _stub_rtk_stats(monkeypatch, [{"lifetime_tokens_saved": 25}]) + + tracker.update_contribution(tokens_saved_cli_filtering=8) + + assert tracker._state.contribution.tokens_saved_cli_filtering == 8 + # rtk comes from the polled delta, not from cli_filtering. + assert tracker._state.contribution.tokens_saved_rtk == 25