Skip to main content

lean_ctx/server/
context_gate.rs

1use crate::core::context_field::{ContextItemId, ContextState};
2use crate::core::context_ledger::{ContextLedger, PressureAction};
3use crate::core::context_overlay::{OverlayOp, OverlayStore};
4
5/// #843: precise, pinned reads (`diff`, `lines:N-M`, `anchored`/`anchored:N-M`)
6/// must pass through every mode-override path untouched — bounce-prevention,
7/// pressure-downgrade, and the graph/knowledge heuristics below must never
8/// silently reinterpret one of these to e.g. `full`, which would discard the
9/// exact window/anchors/delta the caller asked for. Classifies off the typed
10/// `ReadMode` (single source of truth, see `tools::ctx_read::mode`) rather than
11/// a hand-maintained string-prefix list, so a future precise mode has to be
12/// added to `ReadMode::is_precise_pinned_read` explicitly instead of silently
13/// falling through an allowlist. An unparseable mode is conservatively treated
14/// as *not* pinned, matching prior behaviour for anything outside this set.
15fn is_precise_pinned_mode(requested_mode: &str) -> bool {
16    requested_mode
17        .parse::<crate::tools::ctx_read::ReadMode>()
18        .is_ok_and(|m| m.is_precise_pinned_read())
19}
20
21#[derive(Debug, Clone)]
22pub struct PreDispatchResult {
23    pub overridden_mode: Option<String>,
24    pub reason: Option<&'static str>,
25    pub pressure_downgraded: bool,
26    pub budget_blocked: bool,
27    pub budget_warning: Option<String>,
28}
29
30#[derive(Debug, Clone)]
31pub struct PostDispatchResult {
32    pub eviction_hint: Option<String>,
33    pub elicitation_hint: Option<String>,
34    pub resource_changed: bool,
35    /// FEP prefetch suggestion (#9): files likely needed next, from the co-access
36    /// graph. A warmup hint only — never an automatic read.
37    pub prefetch_hint: Option<String>,
38}
39
40pub fn pre_dispatch_read(
41    path: &str,
42    requested_mode: &str,
43    task: Option<&str>,
44    project_root: Option<&str>,
45    pressure: Option<&PressureAction>,
46) -> PreDispatchResult {
47    pre_dispatch_read_for_agent(path, requested_mode, task, project_root, pressure, None)
48}
49
50pub fn pre_dispatch_read_for_agent(
51    path: &str,
52    requested_mode: &str,
53    task: Option<&str>,
54    project_root: Option<&str>,
55    pressure: Option<&PressureAction>,
56    agent_id: Option<&str>,
57) -> PreDispatchResult {
58    let no_change = PreDispatchResult {
59        overridden_mode: None,
60        reason: None,
61        pressure_downgraded: false,
62        budget_blocked: false,
63        budget_warning: None,
64    };
65
66    if let Some(aid) = agent_id {
67        let estimated_tokens = estimate_read_tokens(path, requested_mode);
68        match crate::core::agent_budget::check_budget(aid, estimated_tokens) {
69            crate::core::agent_budget::BudgetCheckResult::Exceeded { limit, consumed } => {
70                return PreDispatchResult {
71                    overridden_mode: None,
72                    reason: Some("agent-budget-exceeded"),
73                    pressure_downgraded: false,
74                    budget_blocked: true,
75                    budget_warning: Some(format!(
76                        "Agent budget exceeded: {consumed}/{limit} tokens consumed. Reset via ctx_session or set a higher limit."
77                    )),
78                };
79            }
80            crate::core::agent_budget::BudgetCheckResult::Warning {
81                remaining,
82                percent_used,
83            } => {
84                let warning = format!(
85                    "[BUDGET WARNING] Agent '{aid}' at {:.0}% budget ({remaining} tokens remaining)",
86                    percent_used * 100.0
87                );
88                let mut result = no_change.clone();
89                result.budget_warning = Some(warning);
90                if is_precise_pinned_mode(requested_mode) {
91                    return result;
92                }
93                let rest = pre_dispatch_inner(path, requested_mode, task, project_root, pressure);
94                return PreDispatchResult {
95                    budget_warning: result.budget_warning,
96                    ..rest
97                };
98            }
99            crate::core::agent_budget::BudgetCheckResult::Allowed { .. } => {}
100        }
101    }
102
103    pre_dispatch_inner(path, requested_mode, task, project_root, pressure)
104}
105
106fn pre_dispatch_inner(
107    path: &str,
108    requested_mode: &str,
109    task: Option<&str>,
110    project_root: Option<&str>,
111    pressure: Option<&PressureAction>,
112) -> PreDispatchResult {
113    let no_change = PreDispatchResult {
114        overridden_mode: None,
115        reason: None,
116        pressure_downgraded: false,
117        budget_blocked: false,
118        budget_warning: None,
119    };
120
121    if is_precise_pinned_mode(requested_mode) {
122        return no_change;
123    }
124
125    if let Some(root) = project_root {
126        let overlay = OverlayStore::load_project(&std::path::PathBuf::from(root));
127        if let Some(result) = check_overlay_mode_override(path, requested_mode, &overlay) {
128            return result;
129        }
130    }
131
132    // Explicit mode=full must not be downgraded by pressure or other heuristics.
133    // Only overlays (user-explicit) above can override it.
134    if requested_mode == "full" {
135        return no_change;
136    }
137
138    if let Some(action) = pressure {
139        let no_degrade = crate::core::config::Config::load().no_degrade_effective();
140        let profile = crate::core::profiles::active_profile();
141        if !no_degrade
142            && profile.degradation.enforce_effective()
143            && let Some(downgraded) = pressure_downgrade(requested_mode, action)
144        {
145            return PreDispatchResult {
146                overridden_mode: Some(downgraded),
147                reason: Some("pressure-auto-downgrade"),
148                pressure_downgraded: true,
149                budget_blocked: false,
150                budget_warning: None,
151            };
152        }
153    }
154
155    if let Ok(bt) = crate::core::bounce_tracker::global().lock()
156        && bt.should_force_full(path)
157    {
158        return PreDispatchResult {
159            overridden_mode: Some("full".to_string()),
160            reason: Some("bounce-prevention"),
161            pressure_downgraded: false,
162            budget_blocked: false,
163            budget_warning: None,
164        };
165    }
166
167    if let Some(task_str) = task {
168        let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
169        let norm = crate::core::pathutil::normalize_tool_path(path);
170        let is_target = intent
171            .targets
172            .iter()
173            .any(|t| norm.ends_with(t) || norm.contains(t));
174        if is_target {
175            return PreDispatchResult {
176                overridden_mode: Some("full".to_string()),
177                reason: Some("intent-target"),
178                pressure_downgraded: false,
179                budget_blocked: false,
180                budget_warning: None,
181            };
182        }
183    }
184
185    if let Some(root) = project_root
186        && let Some(open) = try_load_graph(root)
187    {
188        let gp = &open.provider;
189        let related = gp.related(path, 1);
190        if let Some(task_str) = task {
191            let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
192            for target in &intent.targets {
193                let target_related = gp.related(target, 1);
194                let norm = crate::core::pathutil::normalize_tool_path(path);
195                if target_related
196                    .iter()
197                    .any(|r| r.contains(&norm) || norm.contains(r))
198                {
199                    return PreDispatchResult {
200                        overridden_mode: Some("map".to_string()),
201                        reason: Some("graph-direct-import"),
202                        pressure_downgraded: false,
203                        budget_blocked: false,
204                        budget_warning: None,
205                    };
206                }
207            }
208        }
209        if !related.is_empty() && requested_mode == "auto" {
210            let reverse_deps = gp.dependents(path);
211            if reverse_deps.len() > 3 {
212                return PreDispatchResult {
213                    overridden_mode: Some("map".to_string()),
214                    reason: Some("graph-hub-file"),
215                    pressure_downgraded: false,
216                    budget_blocked: false,
217                    budget_warning: None,
218                };
219            }
220        }
221    }
222
223    if let Some(root) = project_root
224        && let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(root)
225    {
226        let norm = crate::core::pathutil::normalize_tool_path(path);
227        let mentions = knowledge
228            .facts
229            .iter()
230            .filter(|f| f.value.contains(&norm) || f.key.contains(&norm))
231            .count();
232        if mentions >= 3 {
233            return PreDispatchResult {
234                overridden_mode: Some("map".to_string()),
235                reason: Some("knowledge-high-relevance"),
236                pressure_downgraded: false,
237                budget_blocked: false,
238                budget_warning: None,
239            };
240        }
241    }
242
243    no_change
244}
245
246fn estimate_read_tokens(path: &str, mode: &str) -> usize {
247    let file_size = std::fs::metadata(path).map_or(4000, |m| m.len() as usize);
248    let char_estimate = file_size;
249    let full_tokens = char_estimate / 4;
250    match mode {
251        "signatures" => full_tokens / 5,
252        "map" => full_tokens / 3,
253        "aggressive" | "entropy" => full_tokens / 4,
254        "diff" => full_tokens / 10,
255        _ if mode.starts_with("lines:") => {
256            if let Some(range) = mode.strip_prefix("lines:") {
257                // #971: comma is multi-select. Splitting the whole payload on
258                // '-' yields 3 parts for `620-622,1214-1218` and fell through to
259                // the whole-file tenth; sum the selected spans instead.
260                let mut selected = 0usize;
261                for part in range.split(',') {
262                    let part = part.trim();
263                    selected += if let Some((s, e)) = part.split_once('-') {
264                        let start = s.trim().parse::<usize>().unwrap_or(1);
265                        let end = e.trim().parse::<usize>().unwrap_or(start + 100);
266                        end.saturating_sub(start) + 1
267                    } else {
268                        1
269                    };
270                }
271                if selected == 0 {
272                    full_tokens / 10
273                } else {
274                    selected * 10
275                }
276            } else {
277                full_tokens / 10
278            }
279        }
280        _ => full_tokens,
281    }
282}
283
284fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
285    crate::core::auto_mode_resolver::pressure_downgrade(requested_mode, action)
286}
287
288fn check_overlay_mode_override(
289    path: &str,
290    requested_mode: &str,
291    overlay: &OverlayStore,
292) -> Option<PreDispatchResult> {
293    let item_id = ContextItemId::from_file(path);
294    let overlays = overlay.for_item(&item_id);
295
296    for ov in overlays.iter().rev() {
297        match &ov.operation {
298            OverlayOp::SetView(view) => {
299                let mode_str = view.as_str();
300                if mode_str != requested_mode {
301                    return Some(PreDispatchResult {
302                        overridden_mode: Some(mode_str.to_string()),
303                        reason: Some("overlay-set-view"),
304                        pressure_downgraded: false,
305                        budget_blocked: false,
306                        budget_warning: None,
307                    });
308                }
309            }
310            OverlayOp::Pin { .. } if requested_mode != "full" => {
311                return Some(PreDispatchResult {
312                    overridden_mode: Some("full".to_string()),
313                    reason: Some("pinned"),
314                    pressure_downgraded: false,
315                    budget_blocked: false,
316                    budget_warning: None,
317                });
318            }
319            OverlayOp::Exclude { .. } if requested_mode != "signatures" => {
320                return Some(PreDispatchResult {
321                    overridden_mode: Some("signatures".to_string()),
322                    reason: Some("excluded"),
323                    pressure_downgraded: false,
324                    budget_blocked: false,
325                    budget_warning: None,
326                });
327            }
328            _ => {}
329        }
330    }
331    None
332}
333
334pub fn post_dispatch_record(
335    path: &str,
336    mode: &str,
337    original_tokens: usize,
338    sent_tokens: usize,
339    ledger: &mut ContextLedger,
340    overlay: &OverlayStore,
341) -> PostDispatchResult {
342    post_dispatch_record_with_task(
343        path,
344        mode,
345        original_tokens,
346        sent_tokens,
347        ledger,
348        overlay,
349        None,
350        None,
351    )
352}
353
354pub fn post_dispatch_record_with_task(
355    path: &str,
356    mode: &str,
357    original_tokens: usize,
358    sent_tokens: usize,
359    ledger: &mut ContextLedger,
360    overlay: &OverlayStore,
361    task: Option<&str>,
362    project_root: Option<&str>,
363) -> PostDispatchResult {
364    let prev_count = ledger.entries.len();
365    let prev_pressure = ledger.pressure().recommendation;
366
367    ledger.record_with_task(path, mode, original_tokens, sent_tokens, task);
368
369    let item_id = ContextItemId::from_file(path);
370    let state = overlay.apply_to_state(&item_id, ContextState::Included);
371
372    if state == ContextState::Excluded {
373        return PostDispatchResult {
374            eviction_hint: Some(format!("File '{path}' is excluded by overlay.")),
375            elicitation_hint: None,
376            resource_changed: true,
377            prefetch_hint: None,
378        };
379    }
380
381    let elicitation =
382        super::elicitation::check_elicitation_needed(ledger, Some(path), Some(sent_tokens))
383            .map(|s| s.format_fallback_hint());
384
385    let pressure = ledger.pressure();
386
387    // #6 Global-Workspace ignition: salience outliers are broadcast (pinned) into
388    // the working set BEFORE reinjection, so an ignited item keeps its view while
389    // the rest are downgraded under pressure. Deterministic z-score threshold.
390    let ignited = ledger.ignite_high_salience();
391
392    apply_reinjection_plan(ledger, &pressure.recommendation);
393
394    let new_entry = ledger.entries.len() != prev_count;
395    let pressure_shifted = pressure.recommendation != prev_pressure;
396    let resource_changed = new_entry || pressure_shifted || !ignited.is_empty();
397
398    if pressure.utilization > 0.9 {
399        let candidates = ledger.eviction_candidates_by_phi(3);
400        if !candidates.is_empty() {
401            // #715: emit targets the evict resolver can actually find —
402            // root-relative paths (or the full path), never display-shortened
403            // forms that used to produce "Evicted 0/N".
404            let names: Vec<_> = candidates
405                .iter()
406                .take(3)
407                .map(|p| eviction_target_display(p, project_root))
408                .collect();
409            return PostDispatchResult {
410                eviction_hint: Some(format!(
411                    "Context pressure {:.0}%. Evict: ctx_ledger(action=\"evict\", targets=\"{}\")",
412                    pressure.utilization * 100.0,
413                    names.join(", ")
414                )),
415                elicitation_hint: elicitation,
416                resource_changed,
417                // Under pressure we evict rather than prefetch — no warmup hint.
418                prefetch_hint: None,
419            };
420        }
421    }
422
423    // #9 FEP prefetch: with budget to spare, suggest the files most likely needed
424    // next (co-access graph), so the agent can warm them before the surprise of a
425    // miss. Deterministic; runs in the background post-dispatch, never in output.
426    let prefetch_hint =
427        project_root.and_then(|root| crate::core::fep_prefetch::prefetch_hint(root, path, ledger));
428
429    // Context Kernel: generate receipt for delivered context
430    {
431        if let (Some(task_str), Some(root)) = (task, project_root) {
432            let kernel =
433                crate::core::context_kernel::orchestrator::ContextKernel::for_project(root);
434            let ctx = crate::core::context_kernel::types::RetrievalContext {
435                query: task_str.to_owned(),
436                task: Some(task_str.to_owned()),
437                project_root: root.to_owned(),
438                budget: crate::core::context_field::TokenBudget {
439                    total: original_tokens,
440                    used: 0,
441                },
442                max_candidates: 10,
443            };
444            let plan = kernel.plan(&ctx);
445            let receipt = kernel.record_receipt(
446                &plan,
447                sent_tokens,
448                crate::core::context_kernel::types::ReceiptOutcome::Accepted,
449            );
450            let logger =
451                crate::core::context_kernel::shadow::ShadowLogger::default_for_project(root);
452            logger.log_receipt(&receipt);
453        }
454    }
455
456    PostDispatchResult {
457        eviction_hint: None,
458        elicitation_hint: elicitation,
459        resource_changed,
460        prefetch_hint,
461    }
462}
463
464/// #715: a resolvable evict target for hint output — project-root-relative
465/// when the candidate lives under the root, otherwise the full canonical
466/// path. Both forms round-trip through `ContextLedger::resolve_entry`.
467fn eviction_target_display(path: &str, project_root: Option<&str>) -> String {
468    if let Some(root) = project_root.filter(|r| !r.is_empty()) {
469        let root_prefix = format!("{}/", root.trim_end_matches(['/', '\\']).replace('\\', "/"));
470        if let Some(rel) = path.strip_prefix(&root_prefix)
471            && !rel.is_empty()
472        {
473            return rel.to_string();
474        }
475    }
476    path.to_string()
477}
478
479fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
480    if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
481    {
482        return;
483    }
484    for entry in &mut ledger.entries {
485        // #6: ignited / user-pinned items stay broadcast — never downgraded.
486        if entry.state == Some(ContextState::Pinned) {
487            continue;
488        }
489        if entry.mode == "full" {
490            entry.mode = "map".to_string();
491        }
492    }
493}
494
495fn try_load_graph(project_root: &str) -> Option<crate::core::graph_provider::OpenGraphProvider> {
496    crate::core::graph_provider::open_best_effort(project_root)
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn eviction_target_display_emits_resolvable_targets() {
505        // #715: root-relative when under the root, full path otherwise —
506        // never a display-shortened form the resolver cannot find.
507        assert_eq!(
508            eviction_target_display("/w/proj/src/a.rs", Some("/w/proj")),
509            "src/a.rs"
510        );
511        assert_eq!(
512            eviction_target_display("/other/b.rs", Some("/w/proj")),
513            "/other/b.rs"
514        );
515        assert_eq!(eviction_target_display("/x/c.rs", None), "/x/c.rs");
516    }
517
518    #[test]
519    fn pre_dispatch_passthrough_for_full() {
520        let result = pre_dispatch_read("src/main.rs", "full", None, None, None);
521        assert!(result.overridden_mode.is_none());
522    }
523
524    #[test]
525    fn pre_dispatch_passthrough_for_diff() {
526        let result = pre_dispatch_read("src/main.rs", "diff", None, None, None);
527        assert!(result.overridden_mode.is_none());
528    }
529
530    #[test]
531    fn pre_dispatch_passthrough_for_anchored_window() {
532        // #843: a windowed anchored:N-M read must keep its hash anchors —
533        // bounce-prevention, pressure-downgrade, etc. must not clobber it to
534        // "full" and silently drop the window.
535        {
536            let mut bt = crate::core::bounce_tracker::global()
537                .lock()
538                .unwrap_or_else(std::sync::PoisonError::into_inner);
539            bt.set_seq(101);
540            bt.record_read("anchored-bouncy.yml", "map", 30, 400);
541            bt.set_seq(102);
542            bt.record_read("anchored-bouncy.yml", "full", 400, 400);
543            bt.set_seq(103);
544            bt.record_read("a2.yml", "map", 30, 400);
545            bt.set_seq(104);
546            bt.record_read("a2.yml", "full", 400, 400);
547            bt.set_seq(105);
548            bt.record_read("a3.yml", "map", 30, 400);
549            bt.set_seq(106);
550            bt.record_read("a3.yml", "full", 400, 400);
551        }
552        let result = pre_dispatch_read("anchored-new.yml", "anchored:10-40", None, None, None);
553        assert!(
554            result.overridden_mode.is_none(),
555            "anchored:N-M must not be overridden by bounce-prevention"
556        );
557        let bare = pre_dispatch_read("anchored-new.yml", "anchored", None, None, None);
558        assert!(
559            bare.overridden_mode.is_none(),
560            "bare anchored mode must not be overridden by bounce-prevention"
561        );
562    }
563
564    #[test]
565    fn pre_dispatch_passthrough_for_lines_multi_select() {
566        // #971: `lines:A-B,C-D` is a precise pinned window exactly like
567        // `lines:A-B`, but it parsed as Malformed, so `is_precise_pinned_mode`
568        // reported "not pinned" and bounce-prevention rewrote an 8-line request
569        // into a full-file read. Exercises the edit-forced branch, which is what
570        // the field report actually hit (a ctx_patch anchored edit immediately
571        // before the read).
572        {
573            let mut bt = crate::core::bounce_tracker::global()
574                .lock()
575                .unwrap_or_else(std::sync::PoisonError::into_inner);
576            bt.set_seq(201);
577            bt.record_edit("multi-select-971.rs");
578            bt.set_seq(202);
579        }
580
581        // Precondition: the tracker really is armed for this path, so the
582        // assertions below cannot pass vacuously.
583        let forced = pre_dispatch_read("multi-select-971.rs", "map", None, None, None);
584        assert_eq!(
585            forced.overridden_mode.as_deref(),
586            Some("full"),
587            "precondition: a recent edit must force a non-pinned mode to full"
588        );
589
590        let multi = pre_dispatch_read(
591            "multi-select-971.rs",
592            "lines:620-622,1214-1218",
593            None,
594            None,
595            None,
596        );
597        assert!(
598            multi.overridden_mode.is_none(),
599            "lines:A-B,C-D must not be overridden by bounce-prevention (#971)"
600        );
601
602        // Control: the single-range form was already protected.
603        let single = pre_dispatch_read("multi-select-971.rs", "lines:620-622", None, None, None);
604        assert!(
605            single.overridden_mode.is_none(),
606            "lines:A-B must not be overridden by bounce-prevention"
607        );
608    }
609
610    #[test]
611    fn pre_dispatch_no_override_without_signals() {
612        let result = pre_dispatch_read("src/unknown.rs", "auto", None, None, None);
613        assert!(result.overridden_mode.is_none());
614    }
615
616    #[test]
617    fn pre_dispatch_bounce_prevention_forces_full() {
618        {
619            let mut bt = crate::core::bounce_tracker::global()
620                .lock()
621                .unwrap_or_else(std::sync::PoisonError::into_inner);
622            bt.set_seq(1);
623            bt.record_read("src/bouncy.yml", "map", 30, 400);
624            bt.set_seq(2);
625            bt.record_read("src/bouncy.yml", "full", 400, 400);
626            bt.set_seq(3);
627            bt.record_read("a2.yml", "map", 30, 400);
628            bt.set_seq(4);
629            bt.record_read("a2.yml", "full", 400, 400);
630            bt.set_seq(5);
631            bt.record_read("a3.yml", "map", 30, 400);
632            bt.set_seq(6);
633            bt.record_read("a3.yml", "full", 400, 400);
634        }
635        let result = pre_dispatch_read("new.yml", "auto", None, None, None);
636        assert_eq!(result.overridden_mode, Some("full".to_string()));
637        assert_eq!(result.reason, Some("bounce-prevention"));
638    }
639
640    #[test]
641    fn pressure_does_not_downgrade_explicit_full() {
642        let result = pre_dispatch_read(
643            "c.rs",
644            "full",
645            None,
646            None,
647            Some(&PressureAction::ForceCompression),
648        );
649        assert!(
650            result.overridden_mode.is_none(),
651            "explicit mode=full must never be downgraded by pressure"
652        );
653        assert!(!result.pressure_downgraded);
654    }
655
656    #[test]
657    fn pressure_does_not_downgrade_when_enforce_off() {
658        // Default profile has degradation.enforce = false, so pressure
659        // should NOT downgrade any mode.
660        let result = pre_dispatch_read(
661            "c.rs",
662            "map",
663            None,
664            None,
665            Some(&PressureAction::EvictLeastRelevant),
666        );
667        assert!(
668            result.overridden_mode.is_none(),
669            "pressure must not downgrade when degradation.enforce is off"
670        );
671        assert!(!result.pressure_downgraded);
672    }
673
674    #[test]
675    fn no_pressure_downgrade_when_low() {
676        let result = pre_dispatch_read("c.rs", "full", None, None, Some(&PressureAction::NoAction));
677        assert!(result.overridden_mode.is_none());
678        assert!(!result.pressure_downgraded);
679    }
680
681    #[test]
682    fn suggest_compression_does_not_downgrade_when_enforce_off() {
683        // Default profile has degradation.enforce = false
684        let result = pre_dispatch_read(
685            "c.rs",
686            "auto",
687            None,
688            None,
689            Some(&PressureAction::SuggestCompression),
690        );
691        assert!(
692            result.overridden_mode.is_none(),
693            "suggest_compression must not downgrade when enforce is off"
694        );
695        assert!(!result.pressure_downgraded);
696    }
697
698    #[test]
699    fn suggest_compression_does_not_touch_explicit_full() {
700        let result = pre_dispatch_read(
701            "c.rs",
702            "full",
703            None,
704            None,
705            Some(&PressureAction::SuggestCompression),
706        );
707        assert!(result.overridden_mode.is_none());
708        assert!(!result.pressure_downgraded);
709    }
710
711    #[test]
712    fn post_dispatch_reinjection_downgrades_entries() {
713        let mut ledger = ContextLedger::with_window_size(1000);
714        ledger.record("a.rs", "full", 400, 400);
715        ledger.record("b.rs", "full", 400, 400);
716        let overlay = OverlayStore::new();
717        let result = post_dispatch_record("c.rs", "full", 300, 300, &mut ledger, &overlay);
718        assert!(result.resource_changed);
719        let a_entry = ledger.entries.iter().find(|e| e.path == "a.rs").unwrap();
720        assert_eq!(a_entry.mode, "map");
721    }
722
723    #[test]
724    fn ignited_item_resists_reinjection_downgrade() {
725        // #6: a high-salience outlier ignites (pins) and keeps its full view,
726        // while the rest are downgraded to map by pressure reinjection.
727        let mut ledger = ContextLedger::with_window_size(1000);
728        for i in 0..5 {
729            ledger.record(&format!("bg{i}.rs"), "full", 250, 250);
730        }
731        ledger.record("hot.rs", "full", 250, 250);
732        // Set the salience distribution explicitly (record recomputes Phi, so we
733        // overwrite afterwards) to make ignition deterministic in the test.
734        for e in &mut ledger.entries {
735            e.phi = Some(if e.path == "hot.rs" { 0.97 } else { 0.1 });
736        }
737        let ignited = ledger.ignite_high_salience();
738        assert_eq!(ignited, vec!["hot.rs".to_string()], "outlier should ignite");
739
740        apply_reinjection_plan(&mut ledger, &PressureAction::ForceCompression);
741        let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
742        assert_eq!(hot.mode, "full", "ignited item keeps its full view");
743        let bg = ledger.entries.iter().find(|e| e.path == "bg0.rs").unwrap();
744        assert_eq!(bg.mode, "map", "non-ignited items are downgraded");
745    }
746
747    #[test]
748    fn overlay_pin_forces_full_mode() {
749        let dir = tempfile::tempdir().expect("tmp dir");
750        let root = dir.path();
751        let mut store = OverlayStore::new();
752        let target = ContextItemId::from_file("src/important.rs");
753        store.add(crate::core::context_overlay::ContextOverlay::new(
754            target,
755            OverlayOp::Pin { verbatim: false },
756            crate::core::context_overlay::OverlayScope::Project,
757            String::new(),
758            crate::core::context_overlay::OverlayAuthor::User,
759        ));
760        store.save_project(root).unwrap();
761
762        let result = pre_dispatch_read(
763            "src/important.rs",
764            "auto",
765            None,
766            Some(root.to_str().unwrap()),
767            None,
768        );
769        assert_eq!(result.overridden_mode, Some("full".to_string()));
770        assert_eq!(result.reason, Some("pinned"));
771    }
772
773    #[test]
774    fn overlay_exclude_forces_signatures_mode() {
775        let dir = tempfile::tempdir().expect("tmp dir");
776        let root = dir.path();
777        let mut store = OverlayStore::new();
778        let target = ContextItemId::from_file("src/noisy.rs");
779        store.add(crate::core::context_overlay::ContextOverlay::new(
780            target,
781            OverlayOp::Exclude {
782                reason: "noise".to_string(),
783            },
784            crate::core::context_overlay::OverlayScope::Project,
785            String::new(),
786            crate::core::context_overlay::OverlayAuthor::User,
787        ));
788        store.save_project(root).unwrap();
789
790        let result = pre_dispatch_read(
791            "src/noisy.rs",
792            "auto",
793            None,
794            Some(root.to_str().unwrap()),
795            None,
796        );
797        assert_eq!(result.overridden_mode, Some("signatures".to_string()));
798        assert_eq!(result.reason, Some("excluded"));
799    }
800
801    // --- pressure_downgrade unit tests (pure function) ---
802
803    #[test]
804    fn pressure_downgrade_suggest_auto_to_map() {
805        let result = pressure_downgrade("auto", &PressureAction::SuggestCompression);
806        assert_eq!(result, Some("map".to_string()));
807    }
808
809    #[test]
810    fn pressure_downgrade_suggest_full_to_map() {
811        let result = pressure_downgrade("full", &PressureAction::SuggestCompression);
812        assert_eq!(result, Some("map".to_string()));
813    }
814
815    #[test]
816    fn pressure_downgrade_suggest_does_not_touch_signatures() {
817        let result = pressure_downgrade("signatures", &PressureAction::SuggestCompression);
818        assert!(result.is_none());
819    }
820
821    #[test]
822    fn pressure_downgrade_suggest_does_not_touch_diff() {
823        let result = pressure_downgrade("diff", &PressureAction::SuggestCompression);
824        assert!(result.is_none());
825    }
826
827    #[test]
828    fn pressure_downgrade_force_full_to_map() {
829        let result = pressure_downgrade("full", &PressureAction::ForceCompression);
830        assert_eq!(result, Some("map".to_string()));
831    }
832
833    #[test]
834    fn pressure_downgrade_force_auto_to_signatures() {
835        let result = pressure_downgrade("auto", &PressureAction::ForceCompression);
836        assert_eq!(result, Some("signatures".to_string()));
837    }
838
839    #[test]
840    fn pressure_downgrade_force_map_to_signatures() {
841        let result = pressure_downgrade("map", &PressureAction::ForceCompression);
842        assert_eq!(result, Some("signatures".to_string()));
843    }
844
845    #[test]
846    fn pressure_downgrade_force_does_not_touch_signatures() {
847        let result = pressure_downgrade("signatures", &PressureAction::ForceCompression);
848        assert!(result.is_none());
849    }
850
851    #[test]
852    fn pressure_downgrade_force_does_not_touch_lines() {
853        let result = pressure_downgrade("lines:1-50", &PressureAction::ForceCompression);
854        assert!(result.is_none());
855    }
856
857    #[test]
858    fn pressure_downgrade_evict_full_to_map() {
859        let result = pressure_downgrade("full", &PressureAction::EvictLeastRelevant);
860        assert_eq!(result, Some("map".to_string()));
861    }
862
863    #[test]
864    fn pressure_downgrade_evict_auto_to_signatures() {
865        let result = pressure_downgrade("auto", &PressureAction::EvictLeastRelevant);
866        assert_eq!(result, Some("signatures".to_string()));
867    }
868
869    #[test]
870    fn pressure_downgrade_evict_map_to_signatures() {
871        let result = pressure_downgrade("map", &PressureAction::EvictLeastRelevant);
872        assert_eq!(result, Some("signatures".to_string()));
873    }
874
875    #[test]
876    fn pressure_downgrade_noaction_returns_none() {
877        let result = pressure_downgrade("full", &PressureAction::NoAction);
878        assert!(result.is_none());
879    }
880
881    #[test]
882    fn pressure_downgrade_noaction_auto_returns_none() {
883        let result = pressure_downgrade("auto", &PressureAction::NoAction);
884        assert!(result.is_none());
885    }
886
887    // --- pre_dispatch_inner: no_degrade integration ---
888    // When LCTX_NO_DEGRADE is NOT set (test default), pressure downgrade is active.
889
890    #[test]
891    fn pre_dispatch_does_not_downgrade_full_under_force() {
892        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
893            return;
894        }
895        // Explicit mode=full is protected: pressure cannot downgrade it
896        let result = pre_dispatch_read(
897            "nd_test.rs",
898            "full",
899            None,
900            None,
901            Some(&PressureAction::ForceCompression),
902        );
903        assert!(result.overridden_mode.is_none());
904        assert!(!result.pressure_downgraded);
905    }
906
907    #[test]
908    fn pre_dispatch_does_not_downgrade_auto_when_enforce_off() {
909        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
910            return;
911        }
912        // Default profile has degradation.enforce = false, so pressure
913        // should not downgrade even non-full modes
914        let result = pre_dispatch_read(
915            "nd_test2.rs",
916            "auto",
917            None,
918            None,
919            Some(&PressureAction::EvictLeastRelevant),
920        );
921        assert!(result.overridden_mode.is_none());
922        assert!(!result.pressure_downgraded);
923    }
924
925    // --- estimate_read_tokens unit tests ---
926
927    #[test]
928    fn estimate_tokens_diff_mode_is_small() {
929        let tokens = estimate_read_tokens("nonexistent.rs", "diff");
930        assert!(tokens < 500, "diff mode should estimate low: got {tokens}");
931    }
932
933    #[test]
934    fn estimate_tokens_signatures_smaller_than_full() {
935        let sig = estimate_read_tokens("nonexistent.rs", "signatures");
936        let full = estimate_read_tokens("nonexistent.rs", "full");
937        assert!(sig < full, "signatures={sig} should be < full={full}");
938    }
939
940    #[test]
941    fn estimate_tokens_lines_range() {
942        let tokens = estimate_read_tokens("nonexistent.rs", "lines:1-10");
943        assert!(tokens <= 200, "lines:1-10 should be small: got {tokens}");
944    }
945
946    #[test]
947    fn overlay_set_view_forces_specified_mode() {
948        let dir = tempfile::tempdir().expect("tmp dir");
949        let root = dir.path();
950        let mut store = OverlayStore::new();
951        let target = ContextItemId::from_file("src/big.rs");
952        store.add(crate::core::context_overlay::ContextOverlay::new(
953            target,
954            OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
955            crate::core::context_overlay::OverlayScope::Project,
956            String::new(),
957            crate::core::context_overlay::OverlayAuthor::User,
958        ));
959        store.save_project(root).unwrap();
960
961        let result = pre_dispatch_read(
962            "src/big.rs",
963            "auto",
964            None,
965            Some(root.to_str().unwrap()),
966            None,
967        );
968        assert_eq!(result.overridden_mode, Some("map".to_string()));
969        assert_eq!(result.reason, Some("overlay-set-view"));
970    }
971}