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    PostDispatchResult {
430        eviction_hint: None,
431        elicitation_hint: elicitation,
432        resource_changed,
433        prefetch_hint,
434    }
435}
436
437/// #715: a resolvable evict target for hint output — project-root-relative
438/// when the candidate lives under the root, otherwise the full canonical
439/// path. Both forms round-trip through `ContextLedger::resolve_entry`.
440fn eviction_target_display(path: &str, project_root: Option<&str>) -> String {
441    if let Some(root) = project_root.filter(|r| !r.is_empty()) {
442        let root_prefix = format!("{}/", root.trim_end_matches(['/', '\\']).replace('\\', "/"));
443        if let Some(rel) = path.strip_prefix(&root_prefix)
444            && !rel.is_empty()
445        {
446            return rel.to_string();
447        }
448    }
449    path.to_string()
450}
451
452fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
453    if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
454    {
455        return;
456    }
457    for entry in &mut ledger.entries {
458        // #6: ignited / user-pinned items stay broadcast — never downgraded.
459        if entry.state == Some(ContextState::Pinned) {
460            continue;
461        }
462        if entry.mode == "full" {
463            entry.mode = "map".to_string();
464        }
465    }
466}
467
468fn try_load_graph(project_root: &str) -> Option<crate::core::graph_provider::OpenGraphProvider> {
469    crate::core::graph_provider::open_best_effort(project_root)
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn eviction_target_display_emits_resolvable_targets() {
478        // #715: root-relative when under the root, full path otherwise —
479        // never a display-shortened form the resolver cannot find.
480        assert_eq!(
481            eviction_target_display("/w/proj/src/a.rs", Some("/w/proj")),
482            "src/a.rs"
483        );
484        assert_eq!(
485            eviction_target_display("/other/b.rs", Some("/w/proj")),
486            "/other/b.rs"
487        );
488        assert_eq!(eviction_target_display("/x/c.rs", None), "/x/c.rs");
489    }
490
491    #[test]
492    fn pre_dispatch_passthrough_for_full() {
493        let result = pre_dispatch_read("src/main.rs", "full", None, None, None);
494        assert!(result.overridden_mode.is_none());
495    }
496
497    #[test]
498    fn pre_dispatch_passthrough_for_diff() {
499        let result = pre_dispatch_read("src/main.rs", "diff", None, None, None);
500        assert!(result.overridden_mode.is_none());
501    }
502
503    #[test]
504    fn pre_dispatch_passthrough_for_anchored_window() {
505        // #843: a windowed anchored:N-M read must keep its hash anchors —
506        // bounce-prevention, pressure-downgrade, etc. must not clobber it to
507        // "full" and silently drop the window.
508        {
509            let mut bt = crate::core::bounce_tracker::global()
510                .lock()
511                .unwrap_or_else(std::sync::PoisonError::into_inner);
512            bt.set_seq(101);
513            bt.record_read("anchored-bouncy.yml", "map", 30, 400);
514            bt.set_seq(102);
515            bt.record_read("anchored-bouncy.yml", "full", 400, 400);
516            bt.set_seq(103);
517            bt.record_read("a2.yml", "map", 30, 400);
518            bt.set_seq(104);
519            bt.record_read("a2.yml", "full", 400, 400);
520            bt.set_seq(105);
521            bt.record_read("a3.yml", "map", 30, 400);
522            bt.set_seq(106);
523            bt.record_read("a3.yml", "full", 400, 400);
524        }
525        let result = pre_dispatch_read("anchored-new.yml", "anchored:10-40", None, None, None);
526        assert!(
527            result.overridden_mode.is_none(),
528            "anchored:N-M must not be overridden by bounce-prevention"
529        );
530        let bare = pre_dispatch_read("anchored-new.yml", "anchored", None, None, None);
531        assert!(
532            bare.overridden_mode.is_none(),
533            "bare anchored mode must not be overridden by bounce-prevention"
534        );
535    }
536
537    #[test]
538    fn pre_dispatch_passthrough_for_lines_multi_select() {
539        // #971: `lines:A-B,C-D` is a precise pinned window exactly like
540        // `lines:A-B`, but it parsed as Malformed, so `is_precise_pinned_mode`
541        // reported "not pinned" and bounce-prevention rewrote an 8-line request
542        // into a full-file read. Exercises the edit-forced branch, which is what
543        // the field report actually hit (a ctx_patch anchored edit immediately
544        // before the read).
545        {
546            let mut bt = crate::core::bounce_tracker::global()
547                .lock()
548                .unwrap_or_else(std::sync::PoisonError::into_inner);
549            bt.set_seq(201);
550            bt.record_edit("multi-select-971.rs");
551            bt.set_seq(202);
552        }
553
554        // Precondition: the tracker really is armed for this path, so the
555        // assertions below cannot pass vacuously.
556        let forced = pre_dispatch_read("multi-select-971.rs", "map", None, None, None);
557        assert_eq!(
558            forced.overridden_mode.as_deref(),
559            Some("full"),
560            "precondition: a recent edit must force a non-pinned mode to full"
561        );
562
563        let multi = pre_dispatch_read(
564            "multi-select-971.rs",
565            "lines:620-622,1214-1218",
566            None,
567            None,
568            None,
569        );
570        assert!(
571            multi.overridden_mode.is_none(),
572            "lines:A-B,C-D must not be overridden by bounce-prevention (#971)"
573        );
574
575        // Control: the single-range form was already protected.
576        let single = pre_dispatch_read("multi-select-971.rs", "lines:620-622", None, None, None);
577        assert!(
578            single.overridden_mode.is_none(),
579            "lines:A-B must not be overridden by bounce-prevention"
580        );
581    }
582
583    #[test]
584    fn pre_dispatch_no_override_without_signals() {
585        let result = pre_dispatch_read("src/unknown.rs", "auto", None, None, None);
586        assert!(result.overridden_mode.is_none());
587    }
588
589    #[test]
590    fn pre_dispatch_bounce_prevention_forces_full() {
591        {
592            let mut bt = crate::core::bounce_tracker::global()
593                .lock()
594                .unwrap_or_else(std::sync::PoisonError::into_inner);
595            bt.set_seq(1);
596            bt.record_read("src/bouncy.yml", "map", 30, 400);
597            bt.set_seq(2);
598            bt.record_read("src/bouncy.yml", "full", 400, 400);
599            bt.set_seq(3);
600            bt.record_read("a2.yml", "map", 30, 400);
601            bt.set_seq(4);
602            bt.record_read("a2.yml", "full", 400, 400);
603            bt.set_seq(5);
604            bt.record_read("a3.yml", "map", 30, 400);
605            bt.set_seq(6);
606            bt.record_read("a3.yml", "full", 400, 400);
607        }
608        let result = pre_dispatch_read("new.yml", "auto", None, None, None);
609        assert_eq!(result.overridden_mode, Some("full".to_string()));
610        assert_eq!(result.reason, Some("bounce-prevention"));
611    }
612
613    #[test]
614    fn pressure_does_not_downgrade_explicit_full() {
615        let result = pre_dispatch_read(
616            "c.rs",
617            "full",
618            None,
619            None,
620            Some(&PressureAction::ForceCompression),
621        );
622        assert!(
623            result.overridden_mode.is_none(),
624            "explicit mode=full must never be downgraded by pressure"
625        );
626        assert!(!result.pressure_downgraded);
627    }
628
629    #[test]
630    fn pressure_does_not_downgrade_when_enforce_off() {
631        // Default profile has degradation.enforce = false, so pressure
632        // should NOT downgrade any mode.
633        let result = pre_dispatch_read(
634            "c.rs",
635            "map",
636            None,
637            None,
638            Some(&PressureAction::EvictLeastRelevant),
639        );
640        assert!(
641            result.overridden_mode.is_none(),
642            "pressure must not downgrade when degradation.enforce is off"
643        );
644        assert!(!result.pressure_downgraded);
645    }
646
647    #[test]
648    fn no_pressure_downgrade_when_low() {
649        let result = pre_dispatch_read("c.rs", "full", None, None, Some(&PressureAction::NoAction));
650        assert!(result.overridden_mode.is_none());
651        assert!(!result.pressure_downgraded);
652    }
653
654    #[test]
655    fn suggest_compression_does_not_downgrade_when_enforce_off() {
656        // Default profile has degradation.enforce = false
657        let result = pre_dispatch_read(
658            "c.rs",
659            "auto",
660            None,
661            None,
662            Some(&PressureAction::SuggestCompression),
663        );
664        assert!(
665            result.overridden_mode.is_none(),
666            "suggest_compression must not downgrade when enforce is off"
667        );
668        assert!(!result.pressure_downgraded);
669    }
670
671    #[test]
672    fn suggest_compression_does_not_touch_explicit_full() {
673        let result = pre_dispatch_read(
674            "c.rs",
675            "full",
676            None,
677            None,
678            Some(&PressureAction::SuggestCompression),
679        );
680        assert!(result.overridden_mode.is_none());
681        assert!(!result.pressure_downgraded);
682    }
683
684    #[test]
685    fn post_dispatch_reinjection_downgrades_entries() {
686        let mut ledger = ContextLedger::with_window_size(1000);
687        ledger.record("a.rs", "full", 400, 400);
688        ledger.record("b.rs", "full", 400, 400);
689        let overlay = OverlayStore::new();
690        let result = post_dispatch_record("c.rs", "full", 300, 300, &mut ledger, &overlay);
691        assert!(result.resource_changed);
692        let a_entry = ledger.entries.iter().find(|e| e.path == "a.rs").unwrap();
693        assert_eq!(a_entry.mode, "map");
694    }
695
696    #[test]
697    fn ignited_item_resists_reinjection_downgrade() {
698        // #6: a high-salience outlier ignites (pins) and keeps its full view,
699        // while the rest are downgraded to map by pressure reinjection.
700        let mut ledger = ContextLedger::with_window_size(1000);
701        for i in 0..5 {
702            ledger.record(&format!("bg{i}.rs"), "full", 250, 250);
703        }
704        ledger.record("hot.rs", "full", 250, 250);
705        // Set the salience distribution explicitly (record recomputes Phi, so we
706        // overwrite afterwards) to make ignition deterministic in the test.
707        for e in &mut ledger.entries {
708            e.phi = Some(if e.path == "hot.rs" { 0.97 } else { 0.1 });
709        }
710        let ignited = ledger.ignite_high_salience();
711        assert_eq!(ignited, vec!["hot.rs".to_string()], "outlier should ignite");
712
713        apply_reinjection_plan(&mut ledger, &PressureAction::ForceCompression);
714        let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
715        assert_eq!(hot.mode, "full", "ignited item keeps its full view");
716        let bg = ledger.entries.iter().find(|e| e.path == "bg0.rs").unwrap();
717        assert_eq!(bg.mode, "map", "non-ignited items are downgraded");
718    }
719
720    #[test]
721    fn overlay_pin_forces_full_mode() {
722        let dir = tempfile::tempdir().expect("tmp dir");
723        let root = dir.path();
724        let mut store = OverlayStore::new();
725        let target = ContextItemId::from_file("src/important.rs");
726        store.add(crate::core::context_overlay::ContextOverlay::new(
727            target,
728            OverlayOp::Pin { verbatim: false },
729            crate::core::context_overlay::OverlayScope::Project,
730            String::new(),
731            crate::core::context_overlay::OverlayAuthor::User,
732        ));
733        store.save_project(root).unwrap();
734
735        let result = pre_dispatch_read(
736            "src/important.rs",
737            "auto",
738            None,
739            Some(root.to_str().unwrap()),
740            None,
741        );
742        assert_eq!(result.overridden_mode, Some("full".to_string()));
743        assert_eq!(result.reason, Some("pinned"));
744    }
745
746    #[test]
747    fn overlay_exclude_forces_signatures_mode() {
748        let dir = tempfile::tempdir().expect("tmp dir");
749        let root = dir.path();
750        let mut store = OverlayStore::new();
751        let target = ContextItemId::from_file("src/noisy.rs");
752        store.add(crate::core::context_overlay::ContextOverlay::new(
753            target,
754            OverlayOp::Exclude {
755                reason: "noise".to_string(),
756            },
757            crate::core::context_overlay::OverlayScope::Project,
758            String::new(),
759            crate::core::context_overlay::OverlayAuthor::User,
760        ));
761        store.save_project(root).unwrap();
762
763        let result = pre_dispatch_read(
764            "src/noisy.rs",
765            "auto",
766            None,
767            Some(root.to_str().unwrap()),
768            None,
769        );
770        assert_eq!(result.overridden_mode, Some("signatures".to_string()));
771        assert_eq!(result.reason, Some("excluded"));
772    }
773
774    // --- pressure_downgrade unit tests (pure function) ---
775
776    #[test]
777    fn pressure_downgrade_suggest_auto_to_map() {
778        let result = pressure_downgrade("auto", &PressureAction::SuggestCompression);
779        assert_eq!(result, Some("map".to_string()));
780    }
781
782    #[test]
783    fn pressure_downgrade_suggest_full_to_map() {
784        let result = pressure_downgrade("full", &PressureAction::SuggestCompression);
785        assert_eq!(result, Some("map".to_string()));
786    }
787
788    #[test]
789    fn pressure_downgrade_suggest_does_not_touch_signatures() {
790        let result = pressure_downgrade("signatures", &PressureAction::SuggestCompression);
791        assert!(result.is_none());
792    }
793
794    #[test]
795    fn pressure_downgrade_suggest_does_not_touch_diff() {
796        let result = pressure_downgrade("diff", &PressureAction::SuggestCompression);
797        assert!(result.is_none());
798    }
799
800    #[test]
801    fn pressure_downgrade_force_full_to_map() {
802        let result = pressure_downgrade("full", &PressureAction::ForceCompression);
803        assert_eq!(result, Some("map".to_string()));
804    }
805
806    #[test]
807    fn pressure_downgrade_force_auto_to_signatures() {
808        let result = pressure_downgrade("auto", &PressureAction::ForceCompression);
809        assert_eq!(result, Some("signatures".to_string()));
810    }
811
812    #[test]
813    fn pressure_downgrade_force_map_to_signatures() {
814        let result = pressure_downgrade("map", &PressureAction::ForceCompression);
815        assert_eq!(result, Some("signatures".to_string()));
816    }
817
818    #[test]
819    fn pressure_downgrade_force_does_not_touch_signatures() {
820        let result = pressure_downgrade("signatures", &PressureAction::ForceCompression);
821        assert!(result.is_none());
822    }
823
824    #[test]
825    fn pressure_downgrade_force_does_not_touch_lines() {
826        let result = pressure_downgrade("lines:1-50", &PressureAction::ForceCompression);
827        assert!(result.is_none());
828    }
829
830    #[test]
831    fn pressure_downgrade_evict_full_to_map() {
832        let result = pressure_downgrade("full", &PressureAction::EvictLeastRelevant);
833        assert_eq!(result, Some("map".to_string()));
834    }
835
836    #[test]
837    fn pressure_downgrade_evict_auto_to_signatures() {
838        let result = pressure_downgrade("auto", &PressureAction::EvictLeastRelevant);
839        assert_eq!(result, Some("signatures".to_string()));
840    }
841
842    #[test]
843    fn pressure_downgrade_evict_map_to_signatures() {
844        let result = pressure_downgrade("map", &PressureAction::EvictLeastRelevant);
845        assert_eq!(result, Some("signatures".to_string()));
846    }
847
848    #[test]
849    fn pressure_downgrade_noaction_returns_none() {
850        let result = pressure_downgrade("full", &PressureAction::NoAction);
851        assert!(result.is_none());
852    }
853
854    #[test]
855    fn pressure_downgrade_noaction_auto_returns_none() {
856        let result = pressure_downgrade("auto", &PressureAction::NoAction);
857        assert!(result.is_none());
858    }
859
860    // --- pre_dispatch_inner: no_degrade integration ---
861    // When LCTX_NO_DEGRADE is NOT set (test default), pressure downgrade is active.
862
863    #[test]
864    fn pre_dispatch_does_not_downgrade_full_under_force() {
865        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
866            return;
867        }
868        // Explicit mode=full is protected: pressure cannot downgrade it
869        let result = pre_dispatch_read(
870            "nd_test.rs",
871            "full",
872            None,
873            None,
874            Some(&PressureAction::ForceCompression),
875        );
876        assert!(result.overridden_mode.is_none());
877        assert!(!result.pressure_downgraded);
878    }
879
880    #[test]
881    fn pre_dispatch_does_not_downgrade_auto_when_enforce_off() {
882        if std::env::var("LCTX_NO_DEGRADE").is_ok() {
883            return;
884        }
885        // Default profile has degradation.enforce = false, so pressure
886        // should not downgrade even non-full modes
887        let result = pre_dispatch_read(
888            "nd_test2.rs",
889            "auto",
890            None,
891            None,
892            Some(&PressureAction::EvictLeastRelevant),
893        );
894        assert!(result.overridden_mode.is_none());
895        assert!(!result.pressure_downgraded);
896    }
897
898    // --- estimate_read_tokens unit tests ---
899
900    #[test]
901    fn estimate_tokens_diff_mode_is_small() {
902        let tokens = estimate_read_tokens("nonexistent.rs", "diff");
903        assert!(tokens < 500, "diff mode should estimate low: got {tokens}");
904    }
905
906    #[test]
907    fn estimate_tokens_signatures_smaller_than_full() {
908        let sig = estimate_read_tokens("nonexistent.rs", "signatures");
909        let full = estimate_read_tokens("nonexistent.rs", "full");
910        assert!(sig < full, "signatures={sig} should be < full={full}");
911    }
912
913    #[test]
914    fn estimate_tokens_lines_range() {
915        let tokens = estimate_read_tokens("nonexistent.rs", "lines:1-10");
916        assert!(tokens <= 200, "lines:1-10 should be small: got {tokens}");
917    }
918
919    #[test]
920    fn overlay_set_view_forces_specified_mode() {
921        let dir = tempfile::tempdir().expect("tmp dir");
922        let root = dir.path();
923        let mut store = OverlayStore::new();
924        let target = ContextItemId::from_file("src/big.rs");
925        store.add(crate::core::context_overlay::ContextOverlay::new(
926            target,
927            OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
928            crate::core::context_overlay::OverlayScope::Project,
929            String::new(),
930            crate::core::context_overlay::OverlayAuthor::User,
931        ));
932        store.save_project(root).unwrap();
933
934        let result = pre_dispatch_read(
935            "src/big.rs",
936            "auto",
937            None,
938            Some(root.to_str().unwrap()),
939            None,
940        );
941        assert_eq!(result.overridden_mode, Some("map".to_string()));
942        assert_eq!(result.reason, Some("overlay-set-view"));
943    }
944}