Skip to main content

lean_ctx/tools/
server_metrics.rs

1use std::sync::atomic::Ordering;
2
3use super::server::{CepComputedStats, CrpMode, LeanCtxServer, ToolCallRecord};
4use super::startup::auto_consolidate_knowledge;
5use super::{ctx_compress, ctx_share};
6
7/// Build payload-free OCLA metrics for one completed MCP tool call.
8///
9/// Values use milli-units and saturate at the signed metric contract's upper
10/// bound. Tool dimensions are bounded metadata; request payloads and paths
11/// never enter the exporter.
12fn ocla_metric_points(
13    context: &crate::core::ocla::OclaRequestContext,
14    tool: &str,
15    original: usize,
16    saved: usize,
17    duration_ms: u64,
18) -> Vec<crate::core::ocla::MetricPoint> {
19    let mut dimensions = std::collections::BTreeMap::new();
20    dimensions.insert("tool".to_string(), tool.to_string());
21    let original = metric_milli(original as u64);
22    let saved = metric_milli(saved as u64);
23    let duration = metric_milli(duration_ms);
24    vec![
25        crate::core::ocla::MetricPoint {
26            context: context.clone(),
27            name: "mcp.tool.original_tokens".to_string(),
28            value_milli: original,
29            dimensions: dimensions.clone(),
30        },
31        crate::core::ocla::MetricPoint {
32            context: context.clone(),
33            name: "mcp.tool.saved_tokens".to_string(),
34            value_milli: saved,
35            dimensions: dimensions.clone(),
36        },
37        crate::core::ocla::MetricPoint {
38            context: context.clone(),
39            name: "mcp.tool.duration_ms".to_string(),
40            value_milli: duration,
41            dimensions,
42        },
43    ]
44}
45
46fn metric_milli(value: u64) -> i64 {
47    i64::try_from(value.saturating_mul(1_000)).unwrap_or(i64::MAX)
48}
49
50impl LeanCtxServer {
51    /// Records a tool call's token savings without timing information.
52    pub async fn record_call(
53        &self,
54        tool: &str,
55        original: usize,
56        saved: usize,
57        mode: Option<String>,
58    ) {
59        self.record_call_with_timing(tool, original, saved, mode, 0)
60            .await;
61    }
62
63    /// Records a tool call like `record_call`, but includes an optional file
64    /// path for observability and the measured handler duration (#1020 — the
65    /// duration is what makes the row land in `tool-calls.log`).
66    pub async fn record_call_with_path(
67        &self,
68        tool: &str,
69        original: usize,
70        saved: usize,
71        mode: Option<String>,
72        path: Option<&str>,
73        duration_ms: u64,
74    ) {
75        self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, path)
76            .await;
77    }
78
79    /// Records a tool call's token savings, duration, and emits events and stats.
80    pub async fn record_call_with_timing(
81        &self,
82        tool: &str,
83        original: usize,
84        saved: usize,
85        mode: Option<String>,
86        duration_ms: u64,
87    ) {
88        self.record_call_with_timing_inner(tool, original, saved, mode, duration_ms, None)
89            .await;
90    }
91
92    async fn record_call_with_timing_inner(
93        &self,
94        tool: &str,
95        original: usize,
96        saved: usize,
97        mode: Option<String>,
98        duration_ms: u64,
99        path: Option<&str>,
100    ) {
101        if let Some(agent_id) = self.presence_agent_id.read().await.clone()
102            && let Err(error) = crate::core::agents::AgentRegistry::heartbeat_persistent(&agent_id)
103        {
104            tracing::warn!("lean-ctx: failed to update MCP agent heartbeat: {error}");
105        }
106        let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
107        let mut calls = self.tool_calls.write().await;
108        calls.push(ToolCallRecord {
109            tool: tool.to_string(),
110            original_tokens: original,
111            saved_tokens: saved,
112            mode: mode.clone(),
113            duration_ms,
114            timestamp: ts.clone(),
115        });
116
117        const MAX_TOOL_CALL_RECORDS: usize = 500;
118        if calls.len() > MAX_TOOL_CALL_RECORDS {
119            let excess = calls.len() - MAX_TOOL_CALL_RECORDS;
120            calls.drain(..excess);
121        }
122
123        // #1020: persist whenever we have a duration OR real metrics. The old
124        // `duration_ms > 0` gate dropped every read/search row (they record with
125        // measured metrics but were previously logged via a separate zero-filled
126        // path), so `tool-calls.log` only ever showed `orig=0 saved=0 mode=-`.
127        if duration_ms > 0 || original > 0 {
128            Self::append_tool_call_log(tool, duration_ms, original, saved, mode.as_deref(), &ts);
129        }
130
131        crate::core::events::emit_tool_call(
132            tool,
133            original as u64,
134            saved as u64,
135            mode.clone(),
136            duration_ms,
137            path.map(ToString::to_string),
138        );
139
140        let output_tokens = original.saturating_sub(saved);
141        crate::core::stats::record_at_turn(
142            tool,
143            original,
144            output_tokens,
145            crate::core::context_overhead::observed_turns(),
146        );
147        // MCP shell savings are measured (raw vs compressed output), so they are
148        // ledger-grade (GL #479 D2). Reads are ledgered by the ctx_read /
149        // ctx_multi_read callers (#685, decoupled from the heatmap) and ctx_search
150        // records itself — only shell is recorded here, exactly once. `actual_tokens`
151        // is the *sent* output; a prior duplicate block passed `saved` and so both
152        // double-counted shell events and stored the wrong saving (#685).
153        if tool == "ctx_shell" {
154            crate::core::savings_ledger::record_tool_event(
155                tool,
156                original,
157                output_tokens,
158                None,
159                None,
160            );
161        }
162
163        // OCLA ObservationHook: project every MCP tool call as a structured
164        // observation for the canonical observation capability.
165        {
166            let session_r = self.session.read().await;
167            let agent_id = self
168                .presence_agent_id
169                .read()
170                .await
171                .clone()
172                .unwrap_or_default();
173            let ctx = crate::core::ocla::OclaRequestContext {
174                request_id: format!("mcp-{}", self.call_count.load(Ordering::Relaxed)),
175                session_id: session_r.id.clone(),
176                agent_id,
177                content_ref: path.map_or_else(|| format!("tool:{tool}"), |p| format!("file:{p}")),
178                tenant_id: None,
179                trace_id: "tr-unit".into(),
180            };
181            drop(session_r);
182            let observation = crate::core::ocla::Observation {
183                context: ctx.clone(),
184                name: format!("tool_call:{tool}"),
185                attributes: std::collections::BTreeMap::from([
186                    ("original_tokens".into(), original.to_string()),
187                    ("saved_tokens".into(), saved.to_string()),
188                    ("duration_ms".into(), duration_ms.to_string()),
189                ]),
190            };
191            let hook = crate::core::ocla::OclaRegistry::global()
192                .observation_hook
193                .as_ref();
194            let _ = hook.observe(observation).await;
195
196            let outcome = crate::core::ocla::Outcome {
197                context: ctx.clone(),
198                accepted: Some(saved > 0),
199                quality_score_milli: if original > 0 {
200                    Some(((saved as u64 * 1000) / original as u64).min(1000) as u16)
201                } else {
202                    None
203                },
204                outcome_ref: None,
205            };
206            let _ = crate::core::ocla::OclaRegistry::global()
207                .outcome_tracker
208                .record_outcome(outcome);
209
210            // OCLA MetricsExporter projection: one bounded, local batch per
211            // tool call. This is observational only and cannot affect stats,
212            // billing, or external export destinations.
213            let metrics = ocla_metric_points(&ctx, tool, original, saved, duration_ms);
214            let _ = crate::core::ocla::OclaRegistry::global()
215                .metrics_exporter
216                .export_metrics(metrics)
217                .await;
218        }
219        let mut session = self.session.write().await;
220        session.record_tool_call(saved as u64, original as u64);
221        if tool == "ctx_shell" {
222            session.record_command();
223        }
224        let pending_save = if session.should_save() {
225            session.prepare_save().ok()
226        } else {
227            None
228        };
229        drop(calls);
230        drop(session);
231
232        if let Some(prepared) = pending_save {
233            tokio::task::spawn_blocking(move || {
234                let _ = prepared.write_to_disk();
235            });
236        }
237
238        self.write_mcp_live_stats().await;
239        write_science_live_stats();
240    }
241
242    /// Increments the call counter and returns true if a checkpoint is due.
243    pub fn increment_and_check(&self) -> bool {
244        let count = self.call_count.fetch_add(1, Ordering::Relaxed) + 1;
245        let interval = Self::checkpoint_interval_effective();
246        interval > 0 && count.is_multiple_of(interval)
247    }
248
249    /// Generates a compressed context checkpoint with session state and multi-agent sync.
250    pub async fn auto_checkpoint(&self) -> Option<String> {
251        let cache = self.cache.read().await;
252        if cache.get_all_entries().is_empty() {
253            return None;
254        }
255        let complexity = crate::core::adaptive::classify_from_context(&cache);
256        let checkpoint = ctx_compress::handle(&cache, false, CrpMode::effective());
257        drop(cache);
258
259        let mut session = self.session.write().await;
260        let _ = session.save();
261        let session_summary = session.format_compact();
262        let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
263        let project_root = session.project_root.clone();
264        // Snapshot the session under the lock; persist the summary off the hot path.
265        let summary_candidate = crate::core::session_summary::build_candidate(&session);
266        drop(session);
267
268        if has_insights && let Some(ref root) = project_root {
269            let root = root.clone();
270            std::thread::spawn(move || {
271                auto_consolidate_knowledge(&root);
272            });
273        }
274
275        // Periodically record a recallable AI session summary (#292), off-thread.
276        if let Some(ref root) = project_root {
277            let root = root.clone();
278            std::thread::spawn(move || {
279                let _ =
280                    crate::core::session_summary::maybe_record_periodic(&root, summary_candidate);
281            });
282        }
283
284        let multi_agent_block = self
285            .auto_multi_agent_checkpoint(project_root.as_ref())
286            .await;
287
288        self.record_call("ctx_compress", 0, 0, Some("auto".to_string()))
289            .await;
290
291        self.record_cep_snapshot().await;
292
293        if !crate::core::protocol::meta_visible() {
294            return None;
295        }
296
297        let doc_reminder = {
298            let session = self.session.read().await;
299            let calls = self.tool_calls.read().await;
300            Self::activity_nudge(&session, &calls)
301        };
302
303        Some(format!(
304            "{checkpoint}\n\n--- SESSION STATE ---\n{session_summary}\n\n{}{multi_agent_block}{doc_reminder}",
305            complexity.instruction_suffix()
306        ))
307    }
308
309    async fn auto_multi_agent_checkpoint(&self, project_root: Option<&String>) -> String {
310        let Some(root) = project_root else {
311            return String::new();
312        };
313
314        let registry = crate::core::agents::AgentRegistry::load_or_create();
315        let active = registry.list_active(Some(root));
316        if active.len() <= 1 {
317            return String::new();
318        }
319
320        let agent_id = self.agent_id.read().await;
321        let my_id = match agent_id.as_deref() {
322            Some(id) => id.to_string(),
323            None => return String::new(),
324        };
325        drop(agent_id);
326
327        let cache = self.cache.read().await;
328        let entries = cache.get_all_entries();
329        if !entries.is_empty() {
330            let mut by_access: Vec<_> = entries.iter().collect();
331            by_access.sort_by_key(|x| std::cmp::Reverse(x.1.read_count()));
332            let top_paths: Vec<&str> = by_access
333                .iter()
334                .take(5)
335                .map(|(key, _)| key.as_str())
336                .collect();
337            let paths_csv = top_paths.join(",");
338
339            let _ = ctx_share::handle(
340                "push",
341                Some(&my_id),
342                None,
343                Some(&paths_csv),
344                None,
345                &cache,
346                root,
347            );
348        }
349        drop(cache);
350
351        let pending_count = registry
352            .scratchpad
353            .iter()
354            .filter(|e| !e.read_by.contains(&my_id) && e.from_agent != my_id)
355            .count();
356
357        let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
358            .unwrap_or_default()
359            .join("agents")
360            .join("shared");
361        let shared_count = if shared_dir.exists() {
362            std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
363        } else {
364            0
365        };
366
367        let agent_names: Vec<String> = active
368            .iter()
369            .map(|a| {
370                let role = a.role.as_deref().unwrap_or(&a.agent_type);
371                format!("{role}({})", &a.agent_id[..8.min(a.agent_id.len())])
372            })
373            .collect();
374
375        format!(
376            "\n\n--- MULTI-AGENT SYNC ---\nAgents: {} | Pending msgs: {} | Shared contexts: {}\nAuto-shared top-5 cached files.\n--- END SYNC ---",
377            agent_names.join(", "),
378            pending_count,
379            shared_count,
380        )
381    }
382
383    /// Appends a tool call entry to the rotating `tool-calls.log` file.
384    pub fn append_tool_call_log(
385        tool: &str,
386        duration_ms: u64,
387        original: usize,
388        saved: usize,
389        mode: Option<&str>,
390        timestamp: &str,
391    ) {
392        const MAX_LOG_LINES: usize = 50;
393        if let Ok(dir) = crate::core::paths::state_dir() {
394            let log_path = dir.join("tool-calls.log");
395            let mode_str = mode.unwrap_or("-");
396            let slow = if duration_ms > 5000 { " **SLOW**" } else { "" };
397            let line = format!(
398                "{timestamp}\t{tool}\t{duration_ms}ms\torig={original}\tsaved={saved}\tmode={mode_str}{slow}\n"
399            );
400
401            let mut lines: Vec<String> = std::fs::read_to_string(&log_path)
402                .unwrap_or_default()
403                .lines()
404                .map(std::string::ToString::to_string)
405                .collect();
406
407            lines.push(line.trim_end().to_string());
408            if lines.len() > MAX_LOG_LINES {
409                lines.drain(0..lines.len() - MAX_LOG_LINES);
410            }
411
412            let _ = std::fs::write(&log_path, lines.join("\n") + "\n");
413        }
414    }
415
416    fn compute_cep_stats(
417        calls: &[ToolCallRecord],
418        stats: &crate::core::cache::CacheStats,
419        complexity: &crate::core::adaptive::TaskComplexity,
420    ) -> CepComputedStats {
421        let total_original: u64 = calls.iter().map(|c| c.original_tokens as u64).sum();
422        let total_saved: u64 = calls.iter().map(|c| c.saved_tokens as u64).sum();
423        let total_compressed = total_original.saturating_sub(total_saved);
424        let compression_rate = if total_original > 0 {
425            total_saved as f64 / total_original as f64
426        } else {
427            0.0
428        };
429
430        let modes_used: std::collections::HashSet<&str> =
431            calls.iter().filter_map(|c| c.mode.as_deref()).collect();
432        let mode_diversity = (modes_used.len() as f64 / 10.0).min(1.0);
433        let cache_util = stats.hit_rate() / 100.0;
434        // Output efficiency (#501): 1 - avg echo ratio. An agent that keeps
435        // re-quoting delivered content burns the input savings on output.
436        let output_efficiency = 1.0 - crate::core::output_echo::current_avg_ratio();
437        let cep_score = cache_util * 0.25
438            + mode_diversity * 0.15
439            + compression_rate * 0.45
440            + output_efficiency * 0.15;
441
442        let mut mode_counts: std::collections::HashMap<String, u64> =
443            std::collections::HashMap::new();
444        for call in calls {
445            if let Some(ref mode) = call.mode {
446                *mode_counts.entry(mode.clone()).or_insert(0) += 1;
447            }
448        }
449
450        CepComputedStats {
451            cep_score: (cep_score * 100.0).round() as u32,
452            cache_util: (cache_util * 100.0).round() as u32,
453            mode_diversity: (mode_diversity * 100.0).round() as u32,
454            compression_rate: (compression_rate * 100.0).round() as u32,
455            total_original,
456            total_compressed,
457            total_saved,
458            mode_counts,
459            complexity: format!("{complexity:?}"),
460            cache_hits: stats.cache_hits(),
461            total_reads: stats.total_reads(),
462            tool_call_count: calls.len() as u64,
463        }
464    }
465
466    async fn write_mcp_live_stats(&self) {
467        let count = self.call_count.load(Ordering::Relaxed);
468        if count > 1 && !count.is_multiple_of(5) {
469            return;
470        }
471
472        let cache = self.cache.read().await;
473        let calls = self.tool_calls.read().await;
474        let stats = cache.get_stats();
475        let complexity = crate::core::adaptive::classify_from_context(&cache);
476        let dedup = crate::tools::ctx_read::dedup_hook::summary();
477
478        let cs = Self::compute_cep_stats(&calls, stats, &complexity);
479        let started_at = calls
480            .first()
481            .map(|c| c.timestamp.clone())
482            .unwrap_or_default();
483
484        drop(cache);
485        drop(calls);
486
487        // Persist CEP on the live-stats cadence (first call + every 5th) so even
488        // short sessions register `sessions`/`total_cache_hits` instead of only
489        // recording on an `auto_checkpoint` that a brief workload may never reach.
490        // `record_cep_session` is delta-based and PID-guarded, so the extra call
491        // that coincides with a checkpoint is a no-op for the totals (#361).
492        crate::core::stats::record_cep_session(
493            cs.cep_score,
494            cs.cache_hits,
495            cs.total_reads,
496            cs.total_original,
497            cs.total_compressed,
498            &cs.mode_counts,
499            cs.tool_call_count,
500            &cs.complexity,
501        );
502
503        let effective_hits = cs.cache_hits + dedup.dedup_hits as u64;
504        let effective_reads = std::cmp::max(cs.total_reads, dedup.total_reads as u64);
505
506        let source_snapshot = crate::core::auto_mode_resolver::source_counts();
507        let compressed_cache_hits = source_snapshot
508            .iter()
509            .find(|(k, _)| *k == "compressed_cache_hit")
510            .map_or(0, |(_, v)| *v);
511        let full_delivery_degraded = source_snapshot
512            .iter()
513            .find(|(k, _)| *k == "full_delivery_degraded")
514            .map_or(0, |(_, v)| *v);
515
516        let live = serde_json::json!({
517            "cep_score": cs.cep_score,
518            "cache_utilization": cs.cache_util,
519            "mode_diversity": cs.mode_diversity,
520            "compression_rate": cs.compression_rate,
521            "task_complexity": cs.complexity,
522            "files_cached": cs.total_reads,
523            "total_reads": cs.total_reads,
524            "cache_hits": cs.cache_hits,
525            "dedup_reads": dedup.total_reads,
526            "dedup_hits": dedup.dedup_hits,
527            "dedup_tokens_saved": dedup.tokens_saved,
528            "effective_cache_hits": effective_hits,
529            "effective_cache_reads": effective_reads,
530            "compressed_cache_hits": compressed_cache_hits,
531            "full_delivery_degraded": full_delivery_degraded,
532            "tokens_saved": cs.total_saved,
533            "tokens_original": cs.total_original,
534            "tool_calls": cs.tool_call_count,
535            "started_at": started_at,
536            "updated_at": chrono::Local::now().to_rfc3339(),
537        });
538
539        if let Ok(dir) = crate::core::paths::state_dir() {
540            let _ = std::fs::write(dir.join("mcp-live.json"), live.to_string());
541        }
542    }
543
544    /// Persists a CEP (Cognitive Efficiency Protocol) score snapshot for analytics.
545    pub async fn record_cep_snapshot(&self) {
546        let cache = self.cache.read().await;
547        let calls = self.tool_calls.read().await;
548        let stats = cache.get_stats();
549        let complexity = crate::core::adaptive::classify_from_context(&cache);
550
551        let cs = Self::compute_cep_stats(&calls, stats, &complexity);
552
553        drop(cache);
554        drop(calls);
555
556        crate::core::stats::record_cep_session(
557            cs.cep_score,
558            cs.cache_hits,
559            cs.total_reads,
560            cs.total_original,
561            cs.total_compressed,
562            &cs.mode_counts,
563            cs.tool_call_count,
564            &cs.complexity,
565        );
566    }
567
568    fn activity_nudge(
569        session: &crate::core::session::SessionState,
570        calls: &[ToolCallRecord],
571    ) -> &'static str {
572        let last_doc_ts = session
573            .progress
574            .last()
575            .map(|p| p.timestamp)
576            .or_else(|| session.decisions.last().map(|d| d.timestamp))
577            .or_else(|| session.findings.last().map(|f| f.timestamp));
578
579        if let Some(ts) = last_doc_ts {
580            let age = chrono::Utc::now() - ts;
581            if age.num_minutes() < 8 {
582                return "";
583            }
584        }
585
586        let (weighted_score, significant_tools, shell_heavy, edit_heavy) =
587            Self::compute_activity_score(calls, last_doc_ts);
588
589        if weighted_score < 20 || significant_tools < 5 {
590            if session.stats.total_tool_calls >= 30
591                && session.decisions.is_empty()
592                && session.progress.is_empty()
593            {
594                return "\n[CHECKPOINT: please document current progress via ctx_session(action=\"task\") or ctx_knowledge(action=\"remember\")]";
595            }
596            return "";
597        }
598
599        if shell_heavy {
600            "\n[CHECKPOINT: multiple shell commands executed — any test results or findings worth persisting via ctx_knowledge(action=\"remember\")?]"
601        } else if edit_heavy {
602            "\n[CHECKPOINT: several files modified — document the architecture decision or pattern via ctx_knowledge(action=\"remember\")?]"
603        } else {
604            "\n[CHECKPOINT: significant work detected — consider persisting decisions via ctx_knowledge(action=\"remember\")]"
605        }
606    }
607
608    fn compute_activity_score(
609        calls: &[ToolCallRecord],
610        last_doc_ts: Option<chrono::DateTime<chrono::Utc>>,
611    ) -> (u32, u32, bool, bool) {
612        let mut weighted_score: u32 = 0;
613        let mut significant_tools: u32 = 0;
614        let mut shell_count: u32 = 0;
615        let mut edit_count: u32 = 0;
616
617        let since_doc: Vec<&ToolCallRecord> = if let Some(ts) = last_doc_ts {
618            let ts_str = ts.format("%Y-%m-%d %H:%M:%S").to_string();
619            calls.iter().filter(|c| c.timestamp > ts_str).collect()
620        } else {
621            calls.iter().collect()
622        };
623
624        for call in &since_doc {
625            let tool = call.tool.as_str();
626            let is_knowledge = tool == "ctx_knowledge" || tool == "ctx_session";
627            if is_knowledge {
628                weighted_score = 0;
629                significant_tools = 0;
630                shell_count = 0;
631                edit_count = 0;
632                continue;
633            }
634
635            let (weight, significant) = match tool {
636                "edit" | "write" | "str_replace" => {
637                    edit_count += 1;
638                    (4u32, true)
639                }
640                "ctx_shell" => {
641                    shell_count += 1;
642                    let is_test_or_build = call
643                        .mode
644                        .as_deref()
645                        .is_some_and(|m| m.contains("test") || m.contains("build"));
646                    if is_test_or_build {
647                        (3, true)
648                    } else {
649                        (2, true)
650                    }
651                }
652                "ctx_read" => {
653                    let is_cache_hit = call.saved_tokens > 0
654                        && call.original_tokens > 0
655                        && call.saved_tokens == call.original_tokens;
656                    if is_cache_hit { (0, false) } else { (1, false) }
657                }
658                _ => (1, false),
659            };
660
661            weighted_score = weighted_score.saturating_add(weight);
662            if significant {
663                significant_tools += 1;
664            }
665        }
666
667        let shell_heavy = shell_count >= 3 && shell_count > edit_count;
668        let edit_heavy = edit_count >= 3 && edit_count >= shell_count;
669
670        (weighted_score, significant_tools, shell_heavy, edit_heavy)
671    }
672}
673
674/// Persist science-module telemetry for the dashboard (separate process).
675pub(crate) fn write_science_live_stats() {
676    use crate::core::anti_interrupt::{InterruptionEvent, session_interruptions};
677    use crate::core::cognitive_gate;
678
679    let events = session_interruptions();
680    let mut redundant_reads = 0usize;
681    let mut bounce_waste = 0usize;
682    let mut prevented = 0usize;
683    for (event, was_prevented) in &events {
684        if *was_prevented {
685            prevented += 1;
686        }
687        match event {
688            InterruptionEvent::RedundantRead { .. } => redundant_reads += 1,
689            InterruptionEvent::BounceWaste { .. } => bounce_waste += 1,
690            _ => {}
691        }
692    }
693
694    let echo = crate::core::output_echo::load_stats();
695    let impact = crate::core::anti_interrupt::compute_impact();
696
697    let stats = serde_json::json!({
698        "cognitive_mode": if cognitive_gate::full_science_enabled() {
699            "full"
700        } else if cognitive_gate::basic_science_enabled() {
701            "basic"
702        } else {
703            "off"
704        },
705        "anti_interrupt": {
706            "events": events.len(),
707            "redundant_reads": redundant_reads,
708            "bounce_waste": bounce_waste,
709            "prevented": prevented,
710            "score": impact.score,
711            "focus_time_saved_minutes": impact.focus_time_saved_minutes,
712        },
713        "verbosity": {
714            "recommended_level": crate::core::verbosity::recommended_compression()
715                .map(|l| format!("{l:?}")),
716            "auto_applied": crate::core::verbosity::auto_apply_happened(),
717        },
718        "prefetch": {
719            "warmed": crate::core::context_prefetch::warmed_count(),
720            "skipped": crate::core::context_prefetch::skipped_count(),
721        },
722        "echo": {
723            "avg_ratio": echo.avg_ratio(50),
724            "window": echo.reports.len(),
725            "total_analyzed": echo.total_analyzed,
726        },
727        "updated_at": chrono::Local::now().to_rfc3339(),
728    });
729
730    if let Ok(dir) = crate::core::paths::state_dir() {
731        if let Ok(json) = serde_json::to_string_pretty(&stats) {
732            let _ = std::fs::write(dir.join("science-live.json"), json);
733        }
734    }
735}
736
737#[cfg(test)]
738mod activity_score_tests {
739    use super::*;
740
741    fn make_call(tool: &str, mode: Option<&str>) -> ToolCallRecord {
742        ToolCallRecord {
743            tool: tool.to_string(),
744            original_tokens: 100,
745            saved_tokens: 50,
746            mode: mode.map(String::from),
747            duration_ms: 10,
748            timestamp: "2026-01-01 12:00:00".to_string(),
749        }
750    }
751
752    fn make_cache_hit() -> ToolCallRecord {
753        ToolCallRecord {
754            tool: "ctx_read".to_string(),
755            original_tokens: 100,
756            saved_tokens: 100,
757            mode: Some("full".to_string()),
758            duration_ms: 1,
759            timestamp: "2026-01-01 12:00:00".to_string(),
760        }
761    }
762
763    #[test]
764    fn empty_calls_zero_score() {
765        let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&[], None);
766        assert_eq!(score, 0);
767        assert_eq!(sig, 0);
768    }
769
770    #[test]
771    fn edits_have_highest_weight() {
772        let calls = vec![
773            make_call("edit", None),
774            make_call("edit", None),
775            make_call("edit", None),
776        ];
777        let (score, sig, _, edit_heavy) = LeanCtxServer::compute_activity_score(&calls, None);
778        assert_eq!(score, 12);
779        assert_eq!(sig, 3);
780        assert!(edit_heavy);
781    }
782
783    #[test]
784    fn shell_test_build_weight_three() {
785        let calls = vec![
786            make_call("ctx_shell", Some("test")),
787            make_call("ctx_shell", Some("build")),
788            make_call("ctx_shell", Some("test")),
789        ];
790        let (score, sig, shell_heavy, _) = LeanCtxServer::compute_activity_score(&calls, None);
791        assert_eq!(score, 9);
792        assert_eq!(sig, 3);
793        assert!(shell_heavy);
794    }
795
796    #[test]
797    fn cache_hits_zero_weight() {
798        let calls = vec![make_cache_hit(), make_cache_hit(), make_cache_hit()];
799        let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
800        assert_eq!(score, 0);
801        assert_eq!(sig, 0);
802    }
803
804    #[test]
805    fn knowledge_call_resets_score() {
806        let calls = vec![
807            make_call("edit", None),
808            make_call("edit", None),
809            make_call("ctx_knowledge", None),
810            make_call("ctx_read", None),
811        ];
812        let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
813        assert_eq!(score, 1);
814        assert_eq!(sig, 0);
815    }
816
817    #[test]
818    fn mixed_workflow_scoring() {
819        let calls = vec![
820            make_call("ctx_read", None),
821            make_call("ctx_read", None),
822            make_call("edit", None),
823            make_call("edit", None),
824            make_call("ctx_shell", Some("test output")),
825            make_call("ctx_shell", None),
826        ];
827        let (score, sig, _, _) = LeanCtxServer::compute_activity_score(&calls, None);
828        assert_eq!(score, 2 + 4 + 4 + 3 + 2);
829        assert_eq!(sig, 4);
830    }
831
832    #[test]
833    fn ocla_metrics_are_payload_free_and_saturating() {
834        let context = crate::core::ocla::OclaRequestContext {
835            request_id: "mcp-1".into(),
836            session_id: "session-1".into(),
837            agent_id: "agent-1".into(),
838            content_ref: "tool:ctx_read".into(),
839            tenant_id: None,
840            trace_id: "tr-unit".into(),
841        };
842        let points = super::ocla_metric_points(&context, "ctx_read", 750, 125, 42);
843        assert_eq!(points.len(), 3);
844        assert_eq!(points[0].name, "mcp.tool.original_tokens");
845        assert_eq!(points[0].value_milli, 750_000);
846        assert_eq!(points[1].name, "mcp.tool.saved_tokens");
847        assert_eq!(points[1].value_milli, 125_000);
848        assert_eq!(points[2].name, "mcp.tool.duration_ms");
849        assert_eq!(points[2].value_milli, 42_000);
850        assert_eq!(points[0].dimensions["tool"], "ctx_read");
851        assert_eq!(points[0].context, context);
852        assert!(!points[0].dimensions.contains_key("path"));
853
854        let saturated = super::ocla_metric_points(
855            &points[0].context,
856            "ctx_shell",
857            usize::MAX,
858            usize::MAX,
859            u64::MAX,
860        );
861        assert!(saturated.iter().all(|point| point.value_milli == i64::MAX));
862    }
863}