1use crate::core::context_ir::{ContextIrSourceKindV1, ContextIrV1, RecordIrInput};
12use crate::core::context_ledger::ContextLedger;
13use crate::core::heatmap;
14use crate::core::intent_engine::StructuredIntent;
15use crate::core::ocla::EfficiencyAnalyzer;
16use crate::core::session::SessionState;
17use crate::core::stats;
18
19const TRAVERSAL_WINDOW: usize = 6;
23
24pub(crate) fn recent_working_set(session: &SessionState, current: &str) -> Vec<String> {
27 let mut out: Vec<String> = Vec::new();
28 for f in session.files_touched.iter().rev() {
29 if f.path == current || out.contains(&f.path) {
30 continue;
31 }
32 out.push(f.path.clone());
33 if out.len() >= TRAVERSAL_WINDOW {
34 break;
35 }
36 }
37 out
38}
39
40pub(crate) fn usable_root(root: Option<&str>) -> Option<&str> {
42 root.filter(|r| !r.trim().is_empty() && *r != ".")
43}
44
45fn ir_excerpt(text: &str) -> &str {
50 const MAX: usize = 200;
51 if text.len() <= MAX {
52 return text;
53 }
54 let mut end = MAX;
55 while end > 0 && !text.is_char_boundary(end) {
56 end -= 1;
57 }
58 &text[..end]
59}
60
61pub fn record_file_read(
68 path: &str,
69 mode: &str,
70 original_tokens: usize,
71 output_tokens: usize,
72 is_cache_hit: bool,
73 duration: std::time::Duration,
74 output_excerpt: &str,
75) {
76 let saved = original_tokens.saturating_sub(output_tokens);
77 let tool_key = format!("cli_{mode}");
78
79 stats::record(&tool_key, original_tokens, output_tokens);
80 heatmap::record_file_access(path, original_tokens, saved);
81 crate::core::savings_ledger::record_read_event(original_tokens, saved, None, None);
87
88 let mut learning_root = String::from(".");
92
93 if let Some(mut session) = SessionState::load_latest() {
94 session.touch_file(path, None, mode, original_tokens);
95 if is_cache_hit {
96 session.record_cache_hit();
97 crate::core::telemetry::global_metrics().record_cache(true);
98 }
99
100 if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
101 let touched: Vec<String> = session
102 .files_touched
103 .iter()
104 .map(|ft| ft.path.clone())
105 .collect();
106 let inferred = StructuredIntent::from_file_patterns(&touched);
107 if inferred.confidence >= 0.4 {
108 session.active_structured_intent = Some(inferred);
109 }
110 }
111
112 let project_root = session.project_root.clone();
113 if let Some(root) = usable_root(project_root.as_deref()) {
114 learning_root = root.to_string();
115 }
116 let calls = session.stats.total_tool_calls;
117
118 let working_set = recent_working_set(&session, path);
121
122 let _ = session.save();
123
124 if let Some(root) = usable_root(project_root.as_deref()) {
125 crate::core::cooccurrence::record_focus_access(root, path, &working_set);
126 }
127 maybe_consolidate(project_root.as_deref(), calls);
128 }
129
130 if std::path::Path::new(path).is_file() {
134 let mut ledger = ContextLedger::load();
135 ledger.record(path, mode, original_tokens, output_tokens);
136 ledger.save();
137 }
138
139 record_read_learning(
146 path,
147 mode,
148 original_tokens,
149 output_tokens,
150 is_cache_hit,
151 &learning_root,
152 );
153
154 let mut ir = ContextIrV1::load();
160 ir.record(RecordIrInput {
161 kind: ContextIrSourceKindV1::Read,
162 tool: "ctx_read",
163 client_name: None,
164 agent_id: None,
165 path: Some(path),
166 command: None,
167 pattern: Some(mode),
168 input_tokens: original_tokens,
169 output_tokens,
170 duration,
171 content_excerpt: ir_excerpt(output_excerpt),
172 });
173 ir.save();
174
175 if saved > 0 {
179 project_ocla_savings(path, original_tokens as u64, output_tokens as u64);
180 }
181 if mode == "aggressive" && saved > 0 {
182 project_ocla_compression(path, original_tokens as u64, output_tokens as u64);
183 }
184}
185
186fn record_read_learning(
193 path: &str,
194 resolved_mode: &str,
195 original_tokens: usize,
196 output_tokens: usize,
197 is_cache_hit: bool,
198 project_root: &str,
199) {
200 let task_completed = crate::core::bounce_tracker::global()
201 .lock()
202 .ok()
203 .and_then(|bt| bt.bounce_rate_for_extension(path))
204 .is_none_or(|rate| rate < 0.30);
205 let saved = original_tokens.saturating_sub(output_tokens);
206
207 let ocla_density = ocla_read_density(
211 path,
212 resolved_mode,
213 original_tokens,
214 output_tokens,
215 task_completed,
216 project_root,
217 );
218 record_outcome(
219 path,
220 resolved_mode,
221 original_tokens,
222 saved,
223 task_completed,
224 project_root,
225 );
226
227 let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
230 let density = ocla_density.unwrap_or_else(|| {
231 if output_tokens > 0 {
232 original_tokens as f64 / output_tokens as f64
233 } else {
234 1.0
235 }
236 });
237 let outcome = crate::core::mode_predictor::ModeOutcome {
238 mode: resolved_mode.to_string(),
239 tokens_in: original_tokens,
240 tokens_out: output_tokens,
241 density: density.min(1.0),
242 };
243 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
244 predictor.set_project_root(project_root);
245 predictor.record(sig, outcome);
246 predictor.save();
247
248 let ext = std::path::Path::new(path)
252 .extension()
253 .and_then(|e| e.to_str())
254 .unwrap_or("")
255 .to_string();
256 let thresholds = crate::core::adaptive_thresholds::thresholds_for_path(path);
257 let feedback_outcome = crate::core::feedback::CompressionOutcome {
258 session_id: format!("{}", std::process::id()),
259 language: ext,
260 entropy_threshold: thresholds.bpe_entropy,
261 jaccard_threshold: thresholds.jaccard,
262 total_turns: 1,
263 tokens_saved: saved as u64,
264 tokens_original: original_tokens as u64,
265 cache_hits: u32::from(is_cache_hit),
266 total_reads: 1,
267 task_completed,
271 timestamp: chrono::Local::now().to_rfc3339(),
272 };
273 let mut store = crate::core::feedback::FeedbackStore::load();
274 store.project_root = Some(project_root.to_string());
275 store.record_outcome(feedback_outcome);
276
277 crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
281 crate::core::anomaly::save_debounced();
282}
283
284fn record_outcome(
285 path: &str,
286 resolved_mode: &str,
287 original_tokens: usize,
288 saved: usize,
289 task_completed: bool,
290 project_root: &str,
291) {
292 let context = crate::core::ocla::OclaRequestContext {
293 request_id: format!("cli-read:{path}:{resolved_mode}"),
294 session_id: SessionState::load_latest()
295 .map_or_else(|| "cli-read".to_string(), |session| session.id),
296 agent_id: "lean-ctx".to_string(),
297 content_ref: format!("file:{path}"),
298 tenant_id: None,
299 trace_id: "tr-unit".into(),
300 };
301 let outcome = crate::core::ocla::Outcome {
302 context,
303 accepted: Some(task_completed),
304 quality_score_milli: (original_tokens > 0)
305 .then(|| ((saved as u64 * 1000) / original_tokens as u64).min(1000) as u16),
306 outcome_ref: Some(format!("read:{project_root}:{resolved_mode}")),
307 };
308 let _ = crate::core::ocla::OclaRegistry::global()
309 .outcome_tracker
310 .record_outcome(outcome);
311}
312
313fn ocla_read_density(
316 path: &str,
317 resolved_mode: &str,
318 original_tokens: usize,
319 output_tokens: usize,
320 task_completed: bool,
321 project_root: &str,
322) -> Option<f64> {
323 let analyzer = crate::core::ocla::OclaRegistry::global()
324 .efficiency_analyzer
325 .as_ref();
326 read_density_with_analyzer(
327 analyzer,
328 path,
329 resolved_mode,
330 original_tokens,
331 output_tokens,
332 task_completed,
333 project_root,
334 )
335}
336
337fn read_density_with_analyzer(
338 analyzer: &dyn EfficiencyAnalyzer,
339 path: &str,
340 resolved_mode: &str,
341 original_tokens: usize,
342 output_tokens: usize,
343 task_completed: bool,
344 project_root: &str,
345) -> Option<f64> {
346 analyzer
347 .analyze_efficiency(crate::core::ocla::EfficiencySample {
348 context: crate::core::ocla::OclaRequestContext {
349 request_id: format!("read:{path}:{resolved_mode}"),
350 session_id: project_root.to_string(),
351 agent_id: "lean-ctx".to_string(),
352 content_ref: path.to_string(),
353 tenant_id: None,
354 trace_id: "tr-unit".into(),
355 },
356 original_tokens: original_tokens as u64,
357 delivered_tokens: output_tokens as u64,
358 accepted: Some(task_completed),
359 cache_reads: 0,
360 cache_hits: 0,
361 })
362 .ok()
363 .and_then(|analysis| analysis.etpao_milli)
364 .map(|milli| milli as f64 / 1000.0)
365}
366
367pub fn record_search(
374 modeled_baseline: usize,
375 observed_tokens: usize,
376 output_tokens: usize,
377 pattern: &str,
378 path: &str,
379 duration: std::time::Duration,
380 output_excerpt: &str,
381) {
382 stats::record("cli_grep", modeled_baseline, output_tokens);
383 crate::core::savings_ledger::record_tool_event(
384 "cli_grep",
385 observed_tokens,
386 output_tokens,
387 None,
388 None,
389 );
390
391 if let Some(mut session) = SessionState::load_latest() {
392 session.record_command();
393 let project_root = session.project_root.clone();
394 let calls = session.stats.total_tool_calls;
395 let _ = session.save();
396
397 maybe_consolidate(project_root.as_deref(), calls);
398 }
399
400 crate::core::anomaly::record_metric("tokens_per_call", output_tokens as f64);
403 crate::core::anomaly::save_debounced();
404
405 let mut ir = ContextIrV1::load();
409 ir.record(RecordIrInput {
410 kind: ContextIrSourceKindV1::Search,
411 tool: "ctx_search",
412 client_name: None,
413 agent_id: None,
414 path: Some(path),
415 command: None,
416 pattern: Some(pattern),
417 input_tokens: observed_tokens,
418 output_tokens,
419 duration,
420 content_excerpt: ir_excerpt(output_excerpt),
421 });
422 ir.save();
423}
424
425pub fn record_tree(original_tokens: usize, output_tokens: usize) {
427 stats::record("cli_ls", original_tokens, output_tokens);
428
429 if let Some(mut session) = SessionState::load_latest() {
430 session.record_command();
431 let _ = session.save();
432 }
433}
434
435pub fn record_shell_command(original_tokens: usize, output_tokens: usize) {
439 stats::record("cli_shell", original_tokens, output_tokens);
440 crate::core::savings_ledger::record_tool_event(
443 "cli_shell",
444 original_tokens,
445 output_tokens,
446 None,
447 None,
448 );
449
450 if let Some(mut session) = SessionState::load_latest() {
451 session.record_command();
452 let project_root = session.project_root.clone();
453 let calls = session.stats.total_tool_calls;
454 let _ = session.save();
455
456 if original_tokens > 0 {
457 maybe_consolidate(project_root.as_deref(), calls);
458 }
459 }
460}
461
462pub fn flush_all() {
476 stats::flush();
477 heatmap::flush();
478 crate::core::path_mode_memory::flush();
479 crate::core::grammar_usage::flush();
480 crate::core::auto_mode_resolver::flush_sources();
481 crate::core::edit_quality::flush();
482 crate::core::edit_metering::flush();
483 crate::core::mode_predictor::ModePredictor::flush();
484 crate::core::feedback::FeedbackStore::flush();
485 crate::core::threshold_learning::flush();
486 crate::core::litm_calibration::flush();
487}
488
489fn maybe_consolidate(project_root: Option<&str>, calls: u32) {
490 let Some(root) = project_root else { return };
491 let autonomy = crate::core::autonomy::AutonomyState::new();
492 if crate::core::autonomy::should_auto_consolidate(&autonomy, calls) {
493 let root = root.to_string();
494 let _ = crate::core::consolidation_engine::consolidate_latest(
495 &root,
496 crate::core::consolidation_engine::ConsolidationBudgets::default(),
497 );
498 }
499}
500
501fn project_ocla_compression(path: &str, source_tokens: u64, output_tokens: u64) {
505 use crate::core::ocla::OclaRegistry;
506 use crate::core::ocla::types::{CompressionRequest, OclaRequestContext};
507
508 let reg = OclaRegistry::global();
509 let source_ref = format!("file:{path}");
510 let request = CompressionRequest {
511 context: OclaRequestContext {
512 request_id: format!("cli-read-{}", path.len()),
513 session_id: SessionState::load_latest()
514 .map(|s| s.id)
515 .unwrap_or_default(),
516 agent_id: String::new(),
517 content_ref: source_ref.clone(),
518 tenant_id: None,
519 trace_id: "tr-unit".into(),
520 },
521 source_ref,
522 source_tokens,
523 target_tokens: output_tokens,
524 quality_policy_ref: None,
525 };
526 let _ = reg.compression_provider.compress(request);
527}
528
529fn project_ocla_savings(path: &str, original_tokens: u64, output_tokens: u64) {
533 use crate::core::ocla::OclaRegistry;
534 use crate::core::ocla::types::{OclaRequestContext, SavingsEvidence};
535
536 let context = OclaRequestContext {
537 request_id: format!("cli-read-{}", path.len()),
538 session_id: SessionState::load_latest()
539 .map_or_else(|| "cli-read".to_string(), |session| session.id),
540 agent_id: "lean-ctx".to_string(),
541 content_ref: format!("file:{path}"),
542 tenant_id: None,
543 trace_id: String::new(),
544 };
545 let evidence = SavingsEvidence {
546 context,
547 original_tokens,
548 delivered_tokens: output_tokens,
549 quality_ref: None,
550 evidence_ref: format!("read:{path}:{original_tokens}:{output_tokens}"),
551 };
552 let _ = OclaRegistry::global()
553 .savings_ledger
554 .record_savings(evidence);
555}
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use std::sync::atomic::{AtomicUsize, Ordering};
560
561 struct SpyAnalyzer {
562 calls: AtomicUsize,
563 }
564
565 impl crate::core::ocla::OclaService for SpyAnalyzer {
566 fn capability(&self) -> crate::core::ocla::OclaCapability {
567 crate::core::ocla::OclaCapability::available(
568 crate::core::ocla::OclaCapabilityKind::EfficiencyAnalyzer,
569 )
570 }
571 }
572
573 impl EfficiencyAnalyzer for SpyAnalyzer {
574 fn analyze_efficiency(
575 &self,
576 sample: crate::core::ocla::EfficiencySample,
577 ) -> crate::core::ocla::OclaResult<crate::core::ocla::EfficiencyAnalysis> {
578 self.calls.fetch_add(1, Ordering::Relaxed);
579 assert_eq!(sample.original_tokens, 1000);
580 assert_eq!(sample.delivered_tokens, 375);
581 Ok(crate::core::ocla::EfficiencyAnalysis {
582 etpao_milli: sample.accepted.map(|_| 375),
583 duplicate_ratio_milli: 625,
584 compression_rate_milli: 625,
585 cache_hit_rate_milli: 0,
586 recommendation_refs: Vec::new(),
587 })
588 }
589 }
590
591 #[test]
597 fn ocla_read_density_uses_etpao_for_accepted_reads() {
598 let result = ocla_read_density("src/main.rs", "aggressive", 1000, 250, true, ".");
599 assert!(result.is_some(), "accepted read must produce ETPAO");
600 assert!(result.unwrap() > 0.0, "ETPAO must be positive");
601 assert_eq!(
602 ocla_read_density("src/main.rs", "aggressive", 1000, 250, false, "."),
603 None,
604 "unaccepted read must not produce ETPAO"
605 );
606 }
607
608 #[test]
609 fn ocla_read_density_accepts_injected_analyzer() {
610 let spy = SpyAnalyzer {
611 calls: AtomicUsize::new(0),
612 };
613 assert_eq!(
614 read_density_with_analyzer(&spy, "src/main.rs", "full", 1000, 375, true, "."),
615 Some(0.375)
616 );
617 assert_eq!(spy.calls.load(Ordering::Relaxed), 1);
618 }
619
620 #[test]
621 fn record_file_read_does_not_panic_without_session() {
622 let _dir = crate::core::data_dir::isolated_data_dir();
623 record_file_read(
624 "/tmp/nonexistent.rs",
625 "full",
626 100,
627 50,
628 false,
629 std::time::Duration::from_millis(1),
630 "excerpt",
631 );
632 }
633
634 #[test]
635 fn record_search_does_not_panic_without_session() {
636 let _dir = crate::core::data_dir::isolated_data_dir();
637 record_search(
638 500,
639 200,
640 150,
641 "pattern",
642 "/tmp",
643 std::time::Duration::from_millis(1),
644 "matches",
645 );
646 }
647
648 #[test]
649 fn record_tree_does_not_panic_without_session() {
650 let _dir = crate::core::data_dir::isolated_data_dir();
651 record_tree(100, 80);
652 }
653
654 #[test]
655 fn record_shell_does_not_panic_without_session() {
656 let _dir = crate::core::data_dir::isolated_data_dir();
657 record_shell_command(500, 200);
658 }
659
660 #[test]
661 fn flush_all_is_idempotent_and_safe_without_state() {
662 let _dir = crate::core::data_dir::isolated_data_dir();
663 flush_all();
666 flush_all();
667 }
668
669 #[test]
670 fn cli_read_persists_learning_sinks_to_disk() {
671 let dir = crate::core::data_dir::isolated_data_dir();
676 let file = dir.path().join("sample.rs");
677 std::fs::write(&file, "fn main() {\n println!(\"hi\");\n}\n").unwrap();
678 let path = file.to_string_lossy();
679
680 record_file_read(
681 &path,
682 "full",
683 1000,
684 200,
685 false,
686 std::time::Duration::from_millis(2),
687 "sample.rs [3L]\nfn main() {}",
688 );
689 flush_all();
690
691 let data = crate::core::data_dir::lean_ctx_data_dir().expect("data dir");
692 let state = crate::core::paths::state_dir().expect("state dir");
693 assert!(
694 data.join("mode_stats.json").exists(),
695 "mode predictor must persist after a CLI read + flush"
696 );
697 assert!(
698 state.join("feedback.json").exists(),
699 "compression feedback must persist after a CLI read + flush"
700 );
701 assert!(
702 state.join("heatmap.json").exists(),
703 "heatmap must persist after a CLI read + flush"
704 );
705 }
706
707 #[test]
708 fn cli_read_records_context_ir_lineage() {
709 let dir = crate::core::data_dir::isolated_data_dir();
714 let file = dir.path().join("ir_sample.rs");
715 std::fs::write(&file, "fn main() {}\n").unwrap();
716 let path = file.to_string_lossy();
717
718 record_file_read(
719 &path,
720 "full",
721 1000,
722 200,
723 false,
724 std::time::Duration::from_millis(3),
725 "ir_sample.rs [1L]\nfn main() {}",
726 );
727
728 let ir = ContextIrV1::load();
729 assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI read");
730 let item = &ir.items[0];
731 assert_eq!(item.source.tool, "ctx_read");
732 assert!(matches!(item.source.kind, ContextIrSourceKindV1::Read));
733 assert!(
734 item.source
735 .path
736 .as_deref()
737 .unwrap_or("")
738 .ends_with("ir_sample.rs"),
739 "IR records the read path, got {:?}",
740 item.source.path
741 );
742 assert_eq!(item.source.pattern.as_deref(), Some("full"));
743 assert_eq!(item.input_tokens, 1000);
744 assert_eq!(item.output_tokens, 200);
745 assert!(item.duration_us > 0, "a real duration must be recorded");
746 assert!(!item.content_excerpt.is_empty(), "excerpt must be captured");
747 }
748
749 #[test]
750 fn cli_search_records_context_ir_lineage() {
751 let _dir = crate::core::data_dir::isolated_data_dir();
753
754 record_search(
755 800,
756 500,
757 120,
758 "fn handle",
759 "src/",
760 std::time::Duration::from_millis(4),
761 "src/lib.rs:12: fn handle() {}",
762 );
763
764 let ir = ContextIrV1::load();
765 assert_eq!(ir.items.len(), 1, "exactly one IR item per CLI search");
766 let item = &ir.items[0];
767 assert_eq!(item.source.tool, "ctx_search");
768 assert!(matches!(item.source.kind, ContextIrSourceKindV1::Search));
769 assert_eq!(item.input_tokens, 500);
771 assert_eq!(item.output_tokens, 120);
772 assert!(item.duration_us > 0, "a real duration must be recorded");
773 }
774}