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 std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
11use std::time::Duration;
12
13use serde::Serialize;
14
15/// How far back a persisted proxy-introspect record still counts as "the bridge
16/// has intercepted traffic". 24h keeps a running-but-idle proxy classified as
17/// engaged across a normal working day.
18const INTROSPECT_MAX_AGE_SECS: u64 = 86_400;
19
20/// Timeout for the live TCP probe against the proxy port. The probe runs from a
21/// user-invoked status command, so a short budget keeps `gain` responsive.
22const PROXY_PROBE_TIMEOUT: Duration = Duration::from_millis(150);
23
24/// Observable engagement state of the lean-ctx bridge.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum BridgeEngagement {
28    /// Proxy process is not reachable — savings cannot be measured at all.
29    ProxyDown,
30    /// Proxy is up but has not intercepted any LLM request (fresh start, or the
31    /// editor is not routed through the bridge). Savings are unmeasured.
32    NoRequests,
33    /// Proxy is up and has intercepted requests. A reported `0` saved is real.
34    Engaged,
35}
36
37/// Snapshot of the bridge state used to explain `gain`'s savings numbers.
38#[derive(Debug, Clone, Serialize)]
39pub struct BridgeStatus {
40    pub engagement: BridgeEngagement,
41    pub proxy_running: bool,
42    /// Cumulative LLM requests the proxy has intercepted (within the freshness
43    /// window). `0` means the bridge has not seen compressible traffic.
44    pub total_requests: u64,
45    /// Number of MCP tools the registry exposes — the `/lean-ctx` tool-count.
46    pub tool_count: usize,
47}
48
49/// Pure classification of the engagement state from the two raw signals.
50///
51/// Kept separate from [`BridgeStatus::detect`] so the decision logic is unit
52/// testable without touching the network or the filesystem.
53#[must_use]
54pub fn classify(proxy_running: bool, total_requests: u64) -> BridgeEngagement {
55    match (proxy_running, total_requests > 0) {
56        (false, _) => BridgeEngagement::ProxyDown,
57        (true, false) => BridgeEngagement::NoRequests,
58        (true, true) => BridgeEngagement::Engaged,
59    }
60}
61
62impl BridgeStatus {
63    /// Probe the live proxy port and persisted introspection data to determine
64    /// whether the bridge is engaged. Never panics: every signal degrades to a
65    /// conservative default (proxy down / 0 requests) on error.
66    #[must_use]
67    pub fn detect() -> Self {
68        let proxy_running = probe_proxy(crate::proxy_setup::default_port());
69        let total_requests = persisted_request_count(INTROSPECT_MAX_AGE_SECS);
70        let tool_count = crate::server::registry::tool_count();
71        let engagement = classify(proxy_running, total_requests);
72        Self {
73            engagement,
74            proxy_running,
75            total_requests,
76            tool_count,
77        }
78    }
79
80    /// One-line `/lean-ctx`-style status shown above the savings numbers so the
81    /// precondition for savings (bridge connected + tool-count) is always
82    /// explicit.
83    #[must_use]
84    pub fn summary_line(&self) -> String {
85        match self.engagement {
86            BridgeEngagement::Engaged => format!(
87                "Bridge: connected — {} tools, {} requests intercepted",
88                self.tool_count, self.total_requests
89            ),
90            BridgeEngagement::NoRequests => format!(
91                "Bridge: proxy up, 0 requests intercepted — {} tools exposed (route the editor through lean-ctx)",
92                self.tool_count
93            ),
94            BridgeEngagement::ProxyDown => format!(
95                "Bridge: OFF — proxy not reachable; savings cannot be measured ({} tools registered)",
96                self.tool_count
97            ),
98        }
99    }
100
101    /// Explanation for a reported `0` saved, distinguishing "bridge off" from a
102    /// genuine zero. Returns `None` when savings are non-zero (no hint needed).
103    #[must_use]
104    pub fn zero_savings_reason(&self, tokens_saved: u64) -> Option<String> {
105        if tokens_saved > 0 {
106            return None;
107        }
108        Some(match self.engagement {
109            BridgeEngagement::ProxyDown => "saved=0 because the bridge is OFF — the proxy is not \
110                 running, so no requests are intercepted. Start it (`lean-ctx serve`) and confirm \
111                 `/lean-ctx` shows connected; reads/commands will then record savings."
112                .to_string(),
113            BridgeEngagement::NoRequests => {
114                "saved=0 because the proxy has not intercepted any LLM \
115                 request yet. Verify your editor's mcp.json routes through lean-ctx (`/lean-ctx` → \
116                 connected), then retry."
117                    .to_string()
118            }
119            BridgeEngagement::Engaged => "saved=0 is real for this window — the bridge is engaged \
120                 but no compressible context was seen yet (e.g. only cold first reads). Re-run a \
121                 read to populate the cache and savings will appear."
122                .to_string(),
123        })
124    }
125}
126
127/// Live TCP probe: is the proxy accepting connections on `port`?
128fn probe_proxy(port: u16) -> bool {
129    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
130    TcpStream::connect_timeout(&addr, PROXY_PROBE_TIMEOUT).is_ok()
131}
132
133/// Cumulative intercepted-request count from the persisted introspection file,
134/// or `0` when the file is missing, stale, or malformed.
135fn persisted_request_count(max_age_secs: u64) -> u64 {
136    crate::proxy::introspect::load_persisted(max_age_secs)
137        .as_ref()
138        .and_then(|v| v.get("cumulative"))
139        .and_then(|c| c.get("total_requests"))
140        .and_then(serde_json::Value::as_u64)
141        .unwrap_or(0)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn classify_proxy_down_when_not_running() {
150        assert_eq!(classify(false, 0), BridgeEngagement::ProxyDown);
151        // Even with stale request counts, an unreachable proxy is OFF.
152        assert_eq!(classify(false, 1234), BridgeEngagement::ProxyDown);
153    }
154
155    #[test]
156    fn classify_no_requests_when_running_but_idle() {
157        assert_eq!(classify(true, 0), BridgeEngagement::NoRequests);
158    }
159
160    #[test]
161    fn classify_engaged_when_running_with_traffic() {
162        assert_eq!(classify(true, 1), BridgeEngagement::Engaged);
163        assert_eq!(classify(true, 50_000), BridgeEngagement::Engaged);
164    }
165
166    #[test]
167    fn zero_savings_reason_is_none_when_savings_present() {
168        let status = BridgeStatus {
169            engagement: BridgeEngagement::Engaged,
170            proxy_running: true,
171            total_requests: 10,
172            tool_count: 69,
173        };
174        assert!(status.zero_savings_reason(42).is_none());
175    }
176
177    #[test]
178    fn zero_savings_reason_distinguishes_off_from_real_zero() {
179        let off = BridgeStatus {
180            engagement: BridgeEngagement::ProxyDown,
181            proxy_running: false,
182            total_requests: 0,
183            tool_count: 69,
184        };
185        let real = BridgeStatus {
186            engagement: BridgeEngagement::Engaged,
187            proxy_running: 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            total_requests: 7,
204            tool_count: 69,
205        };
206        let line = engaged.summary_line();
207        assert!(line.contains("connected"));
208        assert!(line.contains("69 tools"));
209        assert!(line.contains("7 requests"));
210    }
211}