Skip to main content

lean_ctx/core/gain/
bridge_status.rs

1//! Bridge engagement diagnostics for the `gain` command.
2//!
3//! `gain` derives `tokens_saved` from the proxy's request statistics. When the
4//! lean-ctx bridge (proxy + MCP server) is not engaged those stats stay empty
5//! and `gain` silently reports `0`. Users and external benchmarks (GitHub #361,
6//! #271) cannot then tell "bridge off" apart from "bridge on, genuinely 0
7//! savings". This module observes the engagement state so `gain` can emit an
8//! honest diagnostic instead of a silent zero.
9
10use serde::Serialize;
11
12/// How far back a persisted proxy-introspect record still counts as "the bridge
13/// has intercepted traffic". 24h keeps a running-but-idle proxy classified as
14/// engaged across a normal working day.
15const INTROSPECT_MAX_AGE_SECS: u64 = 86_400;
16
17/// Observable engagement state of the lean-ctx bridge.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum BridgeEngagement {
21    /// Proxy process is not reachable — savings cannot be measured at all.
22    ProxyDown,
23    /// Proxy is up but has not intercepted any LLM request (fresh start, or the
24    /// editor is not routed through the bridge). Savings are unmeasured.
25    NoRequests,
26    /// Proxy is up and has intercepted requests. A reported `0` saved is real.
27    Engaged,
28}
29
30/// Snapshot of the bridge state used to explain `gain`'s savings numbers.
31#[derive(Debug, Clone, Serialize)]
32pub struct BridgeStatus {
33    pub engagement: BridgeEngagement,
34    pub proxy_running: bool,
35    /// Whether the proxy is enabled in config at all (`proxy_enabled = true`).
36    pub proxy_configured: bool,
37    /// Cumulative LLM requests the proxy has intercepted (within the freshness
38    /// window). `0` means the bridge has not seen compressible traffic.
39    pub total_requests: u64,
40    /// Number of MCP tools the registry exposes — the `/lean-ctx` tool-count.
41    pub tool_count: usize,
42}
43
44/// Pure classification of the engagement state from the two raw signals.
45///
46/// Kept separate from [`BridgeStatus::detect`] so the decision logic is unit
47/// testable without touching the network or the filesystem.
48#[must_use]
49pub fn classify(proxy_running: bool, total_requests: u64) -> BridgeEngagement {
50    match (proxy_running, total_requests > 0) {
51        (false, _) => BridgeEngagement::ProxyDown,
52        (true, false) => BridgeEngagement::NoRequests,
53        (true, true) => BridgeEngagement::Engaged,
54    }
55}
56
57impl BridgeStatus {
58    /// Check the configured proxy endpoint and persisted introspection data to
59    /// determine whether the bridge is engaged. Uses the same source of truth as
60    /// `doctor`: the proxy must be enabled in config and answer as lean-ctx (a
61    /// foreign process on the port does not count). Never panics: every signal
62    /// degrades to a conservative default (proxy down / 0 requests) on error.
63    #[must_use]
64    pub fn detect() -> Self {
65        let proxy_configured = crate::core::config::Config::load().proxy_enabled == Some(true);
66        let proxy_running = crate::core::proxy_endpoint::configured_proxy_live();
67        let total_requests = persisted_request_count(INTROSPECT_MAX_AGE_SECS);
68        let tool_count = crate::core::tool_catalog::tool_count();
69        let engagement = classify(proxy_running, total_requests);
70        Self {
71            engagement,
72            proxy_running,
73            proxy_configured,
74            total_requests,
75            tool_count,
76        }
77    }
78
79    /// One-line `/lean-ctx`-style status shown above the savings numbers so the
80    /// precondition for savings (bridge connected + tool-count) is always
81    /// explicit.
82    #[must_use]
83    pub fn summary_line(&self) -> String {
84        match self.engagement {
85            BridgeEngagement::Engaged => format!(
86                "Bridge: connected — {} tools, {} requests intercepted",
87                self.tool_count, self.total_requests
88            ),
89            BridgeEngagement::NoRequests => format!(
90                "Bridge: proxy up, 0 requests intercepted — {} tools exposed (route the editor through lean-ctx)",
91                self.tool_count
92            ),
93            BridgeEngagement::ProxyDown if !self.proxy_configured => format!(
94                "Bridge: OFF — proxy not configured (enable: lean-ctx proxy enable); savings cannot be measured ({} tools registered)",
95                self.tool_count
96            ),
97            BridgeEngagement::ProxyDown => format!(
98                "Bridge: OFF — proxy not reachable; savings cannot be measured ({} tools registered)",
99                self.tool_count
100            ),
101        }
102    }
103
104    /// Explanation for a reported `0` saved, distinguishing "bridge off" from a
105    /// genuine zero. Returns `None` when savings are non-zero (no hint needed).
106    #[must_use]
107    pub fn zero_savings_reason(&self, tokens_saved: u64) -> Option<String> {
108        if tokens_saved > 0 {
109            return None;
110        }
111        Some(match self.engagement {
112            BridgeEngagement::ProxyDown => "saved=0 because the bridge is OFF — the proxy is not \
113                 running, so no requests are intercepted. Start it (`lean-ctx serve`) and confirm \
114                 `/lean-ctx` shows connected; reads/commands will then record savings."
115                .to_string(),
116            BridgeEngagement::NoRequests => {
117                "saved=0 because the proxy has not intercepted any LLM \
118                 request yet. Verify your editor's mcp.json routes through lean-ctx (`/lean-ctx` → \
119                 connected), then retry."
120                    .to_string()
121            }
122            BridgeEngagement::Engaged => "saved=0 is real for this window — the bridge is engaged \
123                 but no compressible context was seen yet (e.g. only cold first reads). Re-run a \
124                 read to populate the cache and savings will appear."
125                .to_string(),
126        })
127    }
128}
129
130/// Cumulative intercepted-request count from the persisted introspection file,
131/// or `0` when the file is missing, stale, or malformed.
132fn persisted_request_count(max_age_secs: u64) -> u64 {
133    crate::core::proxy_introspect::load_persisted(max_age_secs)
134        .as_ref()
135        .and_then(|v| v.get("cumulative"))
136        .and_then(|c| c.get("total_requests"))
137        .and_then(serde_json::Value::as_u64)
138        .unwrap_or(0)
139}
140
141#[cfg(test)]
142pub mod tests {
143    use super::*;
144
145    #[test]
146    fn classify_proxy_down_when_not_running() {
147        assert_eq!(classify(false, 0), BridgeEngagement::ProxyDown);
148        // Even with stale request counts, an unreachable proxy is OFF.
149        assert_eq!(classify(false, 1234), BridgeEngagement::ProxyDown);
150    }
151
152    #[test]
153    fn classify_no_requests_when_running_but_idle() {
154        assert_eq!(classify(true, 0), BridgeEngagement::NoRequests);
155    }
156
157    #[test]
158    fn classify_engaged_when_running_with_traffic() {
159        assert_eq!(classify(true, 1), BridgeEngagement::Engaged);
160        assert_eq!(classify(true, 50_000), BridgeEngagement::Engaged);
161    }
162
163    #[test]
164    fn zero_savings_reason_is_none_when_savings_present() {
165        let status = BridgeStatus {
166            engagement: BridgeEngagement::Engaged,
167            proxy_running: true,
168            proxy_configured: true,
169            total_requests: 10,
170            tool_count: 69,
171        };
172        assert!(status.zero_savings_reason(42).is_none());
173    }
174
175    #[test]
176    fn zero_savings_reason_distinguishes_off_from_real_zero() {
177        let off = BridgeStatus {
178            engagement: BridgeEngagement::ProxyDown,
179            proxy_running: false,
180            proxy_configured: true,
181            total_requests: 0,
182            tool_count: 69,
183        };
184        let real = BridgeStatus {
185            engagement: BridgeEngagement::Engaged,
186            proxy_running: true,
187            proxy_configured: true,
188            total_requests: 10,
189            tool_count: 69,
190        };
191        let off_msg = off.zero_savings_reason(0).expect("off has a reason");
192        let real_msg = real.zero_savings_reason(0).expect("engaged has a reason");
193        assert!(off_msg.contains("bridge is OFF"), "got: {off_msg}");
194        assert!(real_msg.contains("is real"), "got: {real_msg}");
195        assert_ne!(off_msg, real_msg, "off and real-zero must differ");
196    }
197
198    #[test]
199    fn summary_line_reflects_engagement() {
200        let engaged = BridgeStatus {
201            engagement: BridgeEngagement::Engaged,
202            proxy_running: true,
203            proxy_configured: true,
204            total_requests: 7,
205            tool_count: 69,
206        };
207        let line = engaged.summary_line();
208        assert!(line.contains("connected"));
209        assert!(line.contains("69 tools"));
210        assert!(line.contains("7 requests"));
211    }
212}