Skip to main content

devboy_format_pipeline/
strategy.rs

1//! Trimming strategies assign information value to tree nodes.
2//!
3//! Each strategy implements domain-specific value scoring.
4//! The StrategyResolver maps tool names to strategies via:
5//! 1. Exact match in TOML overrides
6//! 2. Hardcoded defaults by tool name
7//! 3. Strip proxy prefix and retry 1-2
8//! 4. Fallback to Default strategy
9
10use std::collections::HashMap;
11
12use crate::tree::{NodeKind, TrimNode};
13
14/// Metadata for priority-aware value scoring.
15///
16/// Passed to `assign_priority_values` alongside the tree.
17/// All fields are optional — missing fields fall back to position-based scoring.
18#[derive(Debug, Clone, Default)]
19pub struct ItemMetadata {
20    /// Number of comments/reactions (activity signal).
21    pub activity: Option<f64>,
22    /// Days since last update (recency signal — lower = more recent = higher value).
23    pub days_since_update: Option<f64>,
24}
25
26/// Strategy type identifier.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum TrimStrategyKind {
29    /// Element count trimming for flat lists (issues, MRs).
30    /// Decreasing value by position; supports full/standard/minimal TrimLevel.
31    ElementCount,
32    /// Cascading trim with chronological decay for comments.
33    /// p(i) = beta^(n-i), beta=0.95 — newest comments are most valuable.
34    Cascading,
35    /// Size-proportional trimming for diffs, weighted by file type.
36    /// .lock=0.05, .min.js=0.10, migrations=0.60, tests=0.70, source=1.00
37    SizeProportional,
38    /// Thread-level tree trimming for discussions.
39    /// resolved=0.3, unresolved=1.0; first+last message always preserved.
40    ThreadLevel,
41    /// Head+Tail trimming for logs.
42    /// 30% head (config/env), 70% tail (errors/results).
43    HeadTail,
44    /// Default uniform strategy — equal value for all nodes.
45    Default,
46    /// Random strategy — shuffle order by seeded RNG (baseline lower bound).
47    Random,
48    /// Reversed strategy — mirror of ElementCount: last item = 1.0, first = 0.3.
49    /// Represents adversarial case where agent needs the last item.
50    Reversed,
51    /// Priority strategy — composite scorer: position⁻¹ × activity × recency.
52    /// Best for get_issues/get_merge_requests when metadata is available.
53    Priority,
54}
55
56impl TrimStrategyKind {
57    /// Parse strategy kind from string.
58    pub fn parse(s: &str) -> Option<Self> {
59        match s {
60            "element_count" => Some(Self::ElementCount),
61            "cascading" => Some(Self::Cascading),
62            "size_proportional" => Some(Self::SizeProportional),
63            "thread_level" => Some(Self::ThreadLevel),
64            "head_tail" => Some(Self::HeadTail),
65            "default" => Some(Self::Default),
66            "random" => Some(Self::Random),
67            "reversed" => Some(Self::Reversed),
68            "priority" => Some(Self::Priority),
69            _ => None,
70        }
71    }
72
73    /// Convert to string representation.
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            Self::ElementCount => "element_count",
77            Self::Cascading => "cascading",
78            Self::SizeProportional => "size_proportional",
79            Self::ThreadLevel => "thread_level",
80            Self::HeadTail => "head_tail",
81            Self::Default => "default",
82            Self::Random => "random",
83            Self::Reversed => "reversed",
84            Self::Priority => "priority",
85        }
86    }
87}
88
89// ============================================================================
90// Strategy trait and implementations
91// ============================================================================
92
93/// Trait for assigning information value to tree nodes.
94pub trait TrimStrategy {
95    /// Assign value scores to all nodes in the tree.
96    fn assign_values(&self, tree: &mut TrimNode);
97}
98
99/// Element count trimming — decreasing value by position.
100pub struct ElementCountStrategy;
101
102impl TrimStrategy for ElementCountStrategy {
103    fn assign_values(&self, tree: &mut TrimNode) {
104        let n = tree.children.len();
105        if n == 0 {
106            return;
107        }
108        for (i, child) in tree.children.iter_mut().enumerate() {
109            // Linear decay: first item = 1.0, last = 0.3
110            child.value = 1.0 - (i as f64 / n as f64) * 0.7;
111            // Propagate to children
112            for grandchild in &mut child.children {
113                grandchild.value = child.value;
114            }
115        }
116    }
117}
118
119/// Cascading trim — chronological decay for comments.
120/// Newest comments are most valuable: p(i) = beta^(n-1-i).
121pub struct CascadingStrategy {
122    /// Decay factor (default 0.95).
123    pub beta: f64,
124}
125
126impl Default for CascadingStrategy {
127    fn default() -> Self {
128        Self { beta: 0.95 }
129    }
130}
131
132impl TrimStrategy for CascadingStrategy {
133    fn assign_values(&self, tree: &mut TrimNode) {
134        let n = tree.children.len();
135        if n == 0 {
136            return;
137        }
138        for (i, child) in tree.children.iter_mut().enumerate() {
139            // Exponential decay: newest = 1.0, oldest = beta^(n-1)
140            child.value = self.beta.powi((n - 1 - i) as i32);
141            for grandchild in &mut child.children {
142                grandchild.value = child.value;
143            }
144        }
145    }
146}
147
148/// Size-proportional trimming for diffs — value weighted by file type.
149pub struct SizeProportionalStrategy;
150
151impl SizeProportionalStrategy {
152    /// Get type weight for a file path.
153    fn file_type_weight(path: &str) -> f64 {
154        let path_lower = path.to_lowercase();
155        if path_lower.ends_with(".lock")
156            || path_lower.ends_with(".sum")
157            || path_lower.ends_with("package-lock.json")
158            || path_lower.ends_with("yarn.lock")
159        {
160            0.05
161        } else if path_lower.ends_with(".min.js") || path_lower.ends_with(".min.css") {
162            0.10
163        } else if path_lower.contains("migration") || path_lower.contains("schema") {
164            0.60
165        } else if path_lower.contains("test") || path_lower.contains("spec") {
166            0.70
167        } else {
168            1.00
169        }
170    }
171}
172
173impl TrimStrategy for SizeProportionalStrategy {
174    fn assign_values(&self, tree: &mut TrimNode) {
175        for child in &mut tree.children {
176            // Extract file path from item index (we use a naming convention)
177            // Since we don't have the original data here, use a default of 1.0
178            // The builder should set a metadata hint on the node
179            let type_weight = if let NodeKind::Item { .. } = &child.kind {
180                // Look for field children named "diff" — they store the file context
181                1.0 // Default; strategy resolver can provide file paths externally
182            } else {
183                1.0
184            };
185            child.value = type_weight;
186            for grandchild in &mut child.children {
187                grandchild.value = type_weight;
188            }
189        }
190    }
191}
192
193/// Assign diff values with explicit file paths.
194pub fn assign_diff_values(tree: &mut TrimNode, file_paths: &[&str]) {
195    for (child, path) in tree.children.iter_mut().zip(file_paths.iter()) {
196        let type_weight = SizeProportionalStrategy::file_type_weight(path);
197        child.value = type_weight;
198        for grandchild in &mut child.children {
199            grandchild.value = type_weight;
200        }
201    }
202}
203
204/// Thread-level trimming for discussions.
205/// resolved=0.3, unresolved=1.0; first+last comment always get value boost.
206pub struct ThreadLevelStrategy;
207
208impl TrimStrategy for ThreadLevelStrategy {
209    fn assign_values(&self, tree: &mut TrimNode) {
210        for child in &mut tree.children {
211            // Default value for discussions — overridden below if resolved info available
212            // Resolved discussions get lower value
213            child.value = 1.0; // Will be set by assign_discussion_values
214
215            // Within each discussion, first and last comments are most valuable
216            let comment_count = child.children.len();
217            for (j, comment) in child.children.iter_mut().enumerate() {
218                if j == 0 || j == comment_count - 1 {
219                    comment.value = 1.0; // First and last always preserved
220                } else {
221                    comment.value = 0.5; // Middle comments are less important
222                }
223            }
224        }
225    }
226}
227
228/// Assign discussion values with resolved status.
229pub fn assign_discussion_values(tree: &mut TrimNode, resolved: &[bool]) {
230    for (child, &is_resolved) in tree.children.iter_mut().zip(resolved.iter()) {
231        let disc_value = if is_resolved { 0.3 } else { 1.0 };
232        child.value = disc_value;
233
234        let comment_count = child.children.len();
235        for (j, comment) in child.children.iter_mut().enumerate() {
236            if j == 0 || j == comment_count - 1 {
237                comment.value = disc_value; // First and last preserved
238            } else {
239                comment.value = disc_value * 0.5;
240            }
241        }
242    }
243}
244
245/// Head+Tail strategy for logs — 30% head, 70% tail with error boost.
246pub struct HeadTailStrategy;
247
248impl TrimStrategy for HeadTailStrategy {
249    fn assign_values(&self, tree: &mut TrimNode) {
250        let n = tree.children.len();
251        if n == 0 {
252            return;
253        }
254        let head_end = (n as f64 * 0.30).ceil() as usize;
255        let tail_start = n - (n as f64 * 0.70).ceil() as usize;
256
257        for (i, child) in tree.children.iter_mut().enumerate() {
258            if i < head_end {
259                child.value = 0.8; // Head: config/env context
260            } else if i >= tail_start {
261                child.value = 1.0; // Tail: errors/results
262            } else {
263                child.value = 0.1; // Middle: least important
264            }
265        }
266    }
267}
268
269/// Default strategy — uniform value 1.0 for all nodes.
270pub struct DefaultStrategy;
271
272impl TrimStrategy for DefaultStrategy {
273    fn assign_values(&self, tree: &mut TrimNode) {
274        set_uniform_value(tree, 1.0);
275    }
276}
277
278fn set_uniform_value(node: &mut TrimNode, value: f64) {
279    node.value = value;
280    for child in &mut node.children {
281        set_uniform_value(child, value);
282    }
283}
284
285/// Random strategy — assigns deterministic pseudo-random values via LCG.
286///
287/// Uses a fixed-seed LCG so results are reproducible across runs.
288/// Serves as the lower-bound baseline in ablation experiments.
289pub struct RandomStrategy {
290    /// Seed for the LCG. Default: 42.
291    pub seed: u64,
292}
293
294impl Default for RandomStrategy {
295    fn default() -> Self {
296        Self { seed: 42 }
297    }
298}
299
300impl TrimStrategy for RandomStrategy {
301    fn assign_values(&self, tree: &mut TrimNode) {
302        let n = tree.children.len();
303        if n == 0 {
304            return;
305        }
306        // LCG: xₙ₊₁ = (a·xₙ + c) mod m  (Knuth parameters)
307        let mut state = self.seed;
308        for child in tree.children.iter_mut() {
309            state = state
310                .wrapping_mul(6364136223846793005)
311                .wrapping_add(1442695040888963407);
312            // Map to (0.0, 1.0]
313            child.value = (state >> 33) as f64 / (u32::MAX as f64);
314            for grandchild in &mut child.children {
315                grandchild.value = child.value;
316            }
317        }
318    }
319}
320
321/// Reversed strategy — mirror of ElementCount: last item = 1.0, first = 0.3.
322///
323/// Represents the adversarial case where the agent needs the last item in the list.
324/// Useful for measuring worst-case p₁ of position-biased strategies.
325pub struct ReversedStrategy;
326
327impl TrimStrategy for ReversedStrategy {
328    fn assign_values(&self, tree: &mut TrimNode) {
329        let n = tree.children.len();
330        if n == 0 {
331            return;
332        }
333        let denom = (n - 1).max(1) as f64;
334        for (i, child) in tree.children.iter_mut().enumerate() {
335            // Linear increase: first item = 0.3, last = 1.0
336            child.value = 0.3 + (i as f64 / denom) * 0.7;
337            for grandchild in &mut child.children {
338                grandchild.value = child.value;
339            }
340        }
341    }
342}
343
344/// Priority strategy — composite scorer for issues and MRs.
345///
346/// `v(i) = w_pos·rank⁻¹ + w_act·activity_norm + w_rec·recency_norm`
347///
348/// When per-item metadata is unavailable, falls back to ElementCount.
349/// Use `assign_priority_values` to supply metadata explicitly.
350pub struct PriorityStrategy;
351
352impl TrimStrategy for PriorityStrategy {
353    fn assign_values(&self, tree: &mut TrimNode) {
354        // Without metadata, fall back to ElementCount scoring
355        ElementCountStrategy.assign_values(tree);
356    }
357}
358
359/// Assign priority values with explicit per-item metadata.
360///
361/// Weights: position_rank=0.4, activity=0.35, recency=0.25.
362/// All signals are normalized to [0, 1] within the item set before combining.
363pub fn assign_priority_values(tree: &mut TrimNode, metadata: &[ItemMetadata]) {
364    let n = tree.children.len();
365    if n == 0 {
366        return;
367    }
368
369    // Collect raw signals, filling missing with positional defaults
370    let activities: Vec<f64> = metadata
371        .iter()
372        .enumerate()
373        .map(|(i, m)| m.activity.unwrap_or(1.0 - i as f64 / n as f64))
374        .collect();
375
376    let recencies: Vec<f64> = metadata
377        .iter()
378        .enumerate()
379        .map(|(i, m)| {
380            // Lower days_since_update → higher recency value
381            m.days_since_update
382                .map(|d| 1.0 / (1.0 + d / 30.0)) // half-life 30 days
383                .unwrap_or(1.0 - i as f64 / n as f64)
384        })
385        .collect();
386
387    // Normalize each signal to [0, 1]
388    let norm = |vals: &[f64]| -> Vec<f64> {
389        let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
390        let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
391        let range = (max - min).max(1e-9);
392        vals.iter().map(|v| (v - min) / range).collect()
393    };
394
395    let act_norm = norm(&activities);
396    let rec_norm = norm(&recencies);
397
398    const W_POS: f64 = 0.40;
399    const W_ACT: f64 = 0.35;
400    const W_REC: f64 = 0.25;
401
402    for (i, child) in tree.children.iter_mut().enumerate() {
403        let pos_score = 1.0 - (i as f64 / n as f64); // higher rank = lower index
404        let act_score = act_norm.get(i).copied().unwrap_or(0.5);
405        let rec_score = rec_norm.get(i).copied().unwrap_or(0.5);
406        child.value = W_POS * pos_score + W_ACT * act_score + W_REC * rec_score;
407        for grandchild in &mut child.children {
408            grandchild.value = child.value;
409        }
410    }
411}
412
413// ============================================================================
414// Strategy creation
415// ============================================================================
416
417/// Create a strategy instance from its kind.
418pub fn create_strategy(kind: TrimStrategyKind) -> Box<dyn TrimStrategy> {
419    match kind {
420        TrimStrategyKind::ElementCount => Box::new(ElementCountStrategy),
421        TrimStrategyKind::Cascading => Box::new(CascadingStrategy::default()),
422        TrimStrategyKind::SizeProportional => Box::new(SizeProportionalStrategy),
423        TrimStrategyKind::ThreadLevel => Box::new(ThreadLevelStrategy),
424        TrimStrategyKind::HeadTail => Box::new(HeadTailStrategy),
425        TrimStrategyKind::Default => Box::new(DefaultStrategy),
426        TrimStrategyKind::Random => Box::new(RandomStrategy::default()),
427        TrimStrategyKind::Reversed => Box::new(ReversedStrategy),
428        TrimStrategyKind::Priority => Box::new(PriorityStrategy),
429    }
430}
431
432// ============================================================================
433// Strategy Resolver
434// ============================================================================
435
436/// Hardcoded default strategies for known tool names.
437fn hardcoded_defaults() -> HashMap<&'static str, TrimStrategyKind> {
438    let mut m = HashMap::new();
439    m.insert("get_issues", TrimStrategyKind::Priority);
440    m.insert("get_issue_comments", TrimStrategyKind::Cascading);
441    m.insert("get_merge_requests", TrimStrategyKind::Priority);
442    m.insert(
443        "get_merge_request_diffs",
444        TrimStrategyKind::SizeProportional,
445    );
446    m.insert(
447        "get_merge_request_discussions",
448        TrimStrategyKind::ThreadLevel,
449    );
450    m.insert("get_job_logs", TrimStrategyKind::HeadTail);
451    m.insert("get_pipeline", TrimStrategyKind::Default);
452    m.insert("get_users", TrimStrategyKind::Default);
453    m.insert("get_statuses", TrimStrategyKind::Default);
454    m
455}
456
457/// Resolves tool names to trimming strategies.
458///
459/// Resolution order:
460/// 1. Exact match in TOML overrides
461/// 2. Exact match in hardcoded defaults
462/// 3. Strip proxy prefix (`cloud__get_issues` → `get_issues`), retry 1-2
463/// 4. Fallback to Default
464pub struct StrategyResolver {
465    hardcoded: HashMap<&'static str, TrimStrategyKind>,
466    overrides: HashMap<String, TrimStrategyKind>,
467    proxy_strip_enabled: bool,
468}
469
470impl StrategyResolver {
471    /// Create resolver with default hardcoded mappings.
472    pub fn new() -> Self {
473        Self {
474            hardcoded: hardcoded_defaults(),
475            overrides: HashMap::new(),
476            proxy_strip_enabled: true,
477        }
478    }
479
480    /// Create resolver with TOML-configured overrides.
481    pub fn with_overrides(overrides: HashMap<String, TrimStrategyKind>) -> Self {
482        Self {
483            hardcoded: hardcoded_defaults(),
484            overrides,
485            proxy_strip_enabled: true,
486        }
487    }
488
489    /// Enable/disable proxy prefix stripping.
490    pub fn set_proxy_strip(&mut self, enabled: bool) {
491        self.proxy_strip_enabled = enabled;
492    }
493
494    /// Resolve tool name to strategy kind.
495    pub fn resolve(&self, tool_name: &str) -> TrimStrategyKind {
496        // 1. Exact match in overrides
497        if let Some(&kind) = self.overrides.get(tool_name) {
498            return kind;
499        }
500
501        // 2. Exact match in hardcoded
502        if let Some(&kind) = self.hardcoded.get(tool_name) {
503            return kind;
504        }
505
506        // 3. Strip proxy prefix and retry
507        if self.proxy_strip_enabled
508            && let Some(stripped) = strip_proxy_prefix(tool_name)
509        {
510            if let Some(&kind) = self.overrides.get(stripped) {
511                return kind;
512            }
513            if let Some(&kind) = self.hardcoded.get(stripped) {
514                return kind;
515            }
516        }
517
518        // 4. Fallback
519        TrimStrategyKind::Default
520    }
521}
522
523impl Default for StrategyResolver {
524    fn default() -> Self {
525        Self::new()
526    }
527}
528
529/// Strip proxy prefix: `cloud__get_issues` → `get_issues`.
530/// Returns None if no prefix found.
531fn strip_proxy_prefix(tool_name: &str) -> Option<&str> {
532    tool_name.find("__").map(|pos| &tool_name[pos + 2..])
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    // --- StrategyResolver ---
540
541    #[test]
542    fn test_resolver_hardcoded_defaults() {
543        let resolver = StrategyResolver::new();
544        // get_issues and get_merge_requests now use Priority (composite scorer)
545        assert_eq!(resolver.resolve("get_issues"), TrimStrategyKind::Priority);
546        assert_eq!(
547            resolver.resolve("get_merge_requests"),
548            TrimStrategyKind::Priority
549        );
550        assert_eq!(
551            resolver.resolve("get_issue_comments"),
552            TrimStrategyKind::Cascading
553        );
554        assert_eq!(
555            resolver.resolve("get_merge_request_diffs"),
556            TrimStrategyKind::SizeProportional
557        );
558        assert_eq!(
559            resolver.resolve("get_merge_request_discussions"),
560            TrimStrategyKind::ThreadLevel
561        );
562        assert_eq!(resolver.resolve("get_job_logs"), TrimStrategyKind::HeadTail);
563        assert_eq!(resolver.resolve("get_pipeline"), TrimStrategyKind::Default);
564    }
565
566    #[test]
567    fn test_resolver_unknown_tool_fallback() {
568        let resolver = StrategyResolver::new();
569        assert_eq!(resolver.resolve("unknown_tool"), TrimStrategyKind::Default);
570    }
571
572    #[test]
573    fn test_resolver_override_takes_priority() {
574        let mut overrides = HashMap::new();
575        overrides.insert("get_issues".into(), TrimStrategyKind::HeadTail);
576        let resolver = StrategyResolver::with_overrides(overrides);
577        assert_eq!(resolver.resolve("get_issues"), TrimStrategyKind::HeadTail);
578    }
579
580    #[test]
581    fn test_resolver_proxy_strip() {
582        let resolver = StrategyResolver::new();
583        assert_eq!(
584            resolver.resolve("cloud__get_issues"),
585            TrimStrategyKind::Priority
586        );
587        assert_eq!(
588            resolver.resolve("jira_proxy__get_issue_comments"),
589            TrimStrategyKind::Cascading
590        );
591    }
592
593    #[test]
594    fn test_resolver_proxy_strip_with_override() {
595        let mut overrides = HashMap::new();
596        overrides.insert("get_tasks".into(), TrimStrategyKind::ElementCount);
597        let resolver = StrategyResolver::with_overrides(overrides);
598        assert_eq!(
599            resolver.resolve("cloud__get_tasks"),
600            TrimStrategyKind::ElementCount
601        );
602    }
603
604    #[test]
605    fn test_resolver_proxy_strip_disabled() {
606        let mut resolver = StrategyResolver::new();
607        resolver.set_proxy_strip(false);
608        assert_eq!(
609            resolver.resolve("cloud__get_issues"),
610            TrimStrategyKind::Default
611        );
612    }
613
614    #[test]
615    fn test_strip_proxy_prefix() {
616        assert_eq!(strip_proxy_prefix("cloud__get_issues"), Some("get_issues"));
617        assert_eq!(
618            strip_proxy_prefix("jira__get_issue_comments"),
619            Some("get_issue_comments")
620        );
621        assert_eq!(strip_proxy_prefix("get_issues"), None);
622        assert_eq!(strip_proxy_prefix("no_prefix"), None);
623    }
624
625    // --- TrimStrategyKind ---
626
627    #[test]
628    fn test_strategy_kind_from_str() {
629        assert_eq!(
630            TrimStrategyKind::parse("element_count"),
631            Some(TrimStrategyKind::ElementCount)
632        );
633        assert_eq!(
634            TrimStrategyKind::parse("cascading"),
635            Some(TrimStrategyKind::Cascading)
636        );
637        assert_eq!(TrimStrategyKind::parse("unknown"), None);
638    }
639
640    #[test]
641    fn test_strategy_kind_round_trip() {
642        let kinds = [
643            TrimStrategyKind::ElementCount,
644            TrimStrategyKind::Cascading,
645            TrimStrategyKind::SizeProportional,
646            TrimStrategyKind::ThreadLevel,
647            TrimStrategyKind::HeadTail,
648            TrimStrategyKind::Default,
649        ];
650        for kind in &kinds {
651            assert_eq!(TrimStrategyKind::parse(kind.as_str()), Some(*kind));
652        }
653    }
654
655    // --- Strategy value assignment ---
656
657    #[test]
658    fn test_element_count_strategy() {
659        let mut tree = make_test_tree(5);
660        ElementCountStrategy.assign_values(&mut tree);
661
662        // First item should have highest value
663        assert!(tree.children[0].value > tree.children[4].value);
664        assert!((tree.children[0].value - 1.0).abs() < 0.01);
665        assert!(tree.children[4].value >= 0.3);
666    }
667
668    #[test]
669    fn test_cascading_strategy() {
670        let mut tree = make_test_tree(10);
671        CascadingStrategy::default().assign_values(&mut tree);
672
673        // Last item (newest) should have highest value
674        assert!(tree.children[9].value > tree.children[0].value);
675        // Oldest of 10 with beta=0.95: 0.95^9 ≈ 0.63
676        assert!(tree.children[0].value < 0.7);
677    }
678
679    #[test]
680    fn test_head_tail_strategy() {
681        let mut tree = make_test_tree(100);
682        HeadTailStrategy.assign_values(&mut tree);
683
684        // Head items (first 30%)
685        assert!((tree.children[0].value - 0.8).abs() < 0.01);
686        assert!((tree.children[29].value - 0.8).abs() < 0.01);
687        // Tail items (last 70% — starts at index 30)
688        assert!((tree.children[99].value - 1.0).abs() < 0.01);
689        // With 100 items, head_end=30, tail_start=30, so no middle items
690        // Test with larger range to have a real middle
691        let mut tree2 = make_test_tree(200);
692        HeadTailStrategy.assign_values(&mut tree2);
693        // head_end = ceil(200 * 0.30) = 60
694        // tail_start = 200 - ceil(200 * 0.70) = 200 - 140 = 60
695        // So still no middle for balanced split. Let's just verify head < tail
696        assert!(tree2.children[0].value < tree2.children[199].value);
697    }
698
699    #[test]
700    fn test_default_strategy() {
701        let mut tree = make_test_tree(5);
702        DefaultStrategy.assign_values(&mut tree);
703
704        for child in &tree.children {
705            assert!((child.value - 1.0).abs() < 0.001);
706        }
707    }
708
709    #[test]
710    fn test_assign_diff_values() {
711        let mut tree = make_test_tree(3);
712        let paths = ["Cargo.lock", "src/main.rs", "test_helper.rs"];
713        assign_diff_values(&mut tree, &paths);
714
715        assert!((tree.children[0].value - 0.05).abs() < 0.01); // .lock
716        assert!((tree.children[1].value - 1.0).abs() < 0.01); // source
717        assert!((tree.children[2].value - 0.70).abs() < 0.01); // test
718    }
719
720    #[test]
721    fn test_assign_discussion_values() {
722        let mut tree = TrimNode::new(0, NodeKind::Root, 0);
723        for i in 0..3 {
724            let mut disc = TrimNode::new(i + 1, NodeKind::Item { index: i }, 10);
725            for j in 0..3 {
726                let comment = TrimNode::new(10 + i * 3 + j, NodeKind::Item { index: j }, 5);
727                disc.children.push(comment);
728            }
729            tree.children.push(disc);
730        }
731
732        let resolved = [true, false, true];
733        assign_discussion_values(&mut tree, &resolved);
734
735        assert!((tree.children[0].value - 0.3).abs() < 0.01); // resolved
736        assert!((tree.children[1].value - 1.0).abs() < 0.01); // unresolved
737        assert!((tree.children[2].value - 0.3).abs() < 0.01); // resolved
738    }
739
740    // --- SizeProportionalStrategy ---
741
742    #[test]
743    fn test_size_proportional_strategy() {
744        let mut tree = make_test_tree(3);
745        SizeProportionalStrategy.assign_values(&mut tree);
746        // Default value is 1.0 for all items
747        for child in &tree.children {
748            assert!((child.value - 1.0).abs() < 0.01);
749        }
750    }
751
752    #[test]
753    fn test_file_type_weights() {
754        assert!((SizeProportionalStrategy::file_type_weight("Cargo.lock") - 0.05).abs() < 0.01);
755        assert!(
756            (SizeProportionalStrategy::file_type_weight("package-lock.json") - 0.05).abs() < 0.01
757        );
758        assert!((SizeProportionalStrategy::file_type_weight("yarn.lock") - 0.05).abs() < 0.01);
759        assert!((SizeProportionalStrategy::file_type_weight("go.sum") - 0.05).abs() < 0.01);
760        assert!((SizeProportionalStrategy::file_type_weight("app.min.js") - 0.10).abs() < 0.01);
761        assert!((SizeProportionalStrategy::file_type_weight("style.min.css") - 0.10).abs() < 0.01);
762        assert!(
763            (SizeProportionalStrategy::file_type_weight("db/migration_001.sql") - 0.60).abs()
764                < 0.01
765        );
766        assert!((SizeProportionalStrategy::file_type_weight("schema.prisma") - 0.60).abs() < 0.01);
767        assert!((SizeProportionalStrategy::file_type_weight("test_main.rs") - 0.70).abs() < 0.01);
768        assert!((SizeProportionalStrategy::file_type_weight("main.spec.ts") - 0.70).abs() < 0.01);
769        assert!((SizeProportionalStrategy::file_type_weight("src/main.rs") - 1.0).abs() < 0.01);
770    }
771
772    // --- ThreadLevelStrategy ---
773
774    #[test]
775    fn test_thread_level_strategy() {
776        let mut tree = TrimNode::new(0, NodeKind::Root, 0);
777        for i in 0..2 {
778            let mut disc = TrimNode::new(i + 1, NodeKind::Item { index: i }, 10);
779            for j in 0..4 {
780                let comment = TrimNode::new(10 + i * 4 + j, NodeKind::Item { index: j }, 5);
781                disc.children.push(comment);
782            }
783            tree.children.push(disc);
784        }
785
786        ThreadLevelStrategy.assign_values(&mut tree);
787
788        // First and last comments should have value 1.0
789        assert!((tree.children[0].children[0].value - 1.0).abs() < 0.01);
790        assert!((tree.children[0].children[3].value - 1.0).abs() < 0.01);
791        // Middle comments should have 0.5
792        assert!((tree.children[0].children[1].value - 0.5).abs() < 0.01);
793        assert!((tree.children[0].children[2].value - 0.5).abs() < 0.01);
794    }
795
796    // --- create_strategy ---
797
798    #[test]
799    fn test_create_strategy_all_kinds() {
800        let kinds = [
801            TrimStrategyKind::ElementCount,
802            TrimStrategyKind::Cascading,
803            TrimStrategyKind::SizeProportional,
804            TrimStrategyKind::ThreadLevel,
805            TrimStrategyKind::HeadTail,
806            TrimStrategyKind::Default,
807            TrimStrategyKind::Random,
808            TrimStrategyKind::Reversed,
809            TrimStrategyKind::Priority,
810        ];
811        for kind in &kinds {
812            let strategy = create_strategy(*kind);
813            let mut tree = make_test_tree(5);
814            strategy.assign_values(&mut tree);
815            // Should not panic
816        }
817    }
818
819    // --- Empty tree edge cases ---
820
821    #[test]
822    fn test_strategies_on_empty_tree() {
823        let strategies: Vec<Box<dyn TrimStrategy>> = vec![
824            Box::new(ElementCountStrategy),
825            Box::new(CascadingStrategy::default()),
826            Box::new(SizeProportionalStrategy),
827            Box::new(ThreadLevelStrategy),
828            Box::new(HeadTailStrategy),
829            Box::new(DefaultStrategy),
830            Box::new(RandomStrategy::default()),
831            Box::new(ReversedStrategy),
832            Box::new(PriorityStrategy),
833        ];
834        for strategy in &strategies {
835            let mut tree = TrimNode::new(0, NodeKind::Root, 0);
836            strategy.assign_values(&mut tree); // Should not panic
837        }
838    }
839
840    // --- assign_diff_values edge cases ---
841
842    #[test]
843    fn test_assign_diff_values_various_types() {
844        let mut tree = make_test_tree(5);
845        let paths = [
846            "Cargo.lock",
847            "bundle.min.js",
848            "db/migration_v2.sql",
849            "src/tests/test_auth.rs",
850            "src/lib.rs",
851        ];
852        assign_diff_values(&mut tree, &paths);
853
854        assert!(tree.children[0].value < tree.children[4].value); // lock < source
855        assert!(tree.children[1].value < tree.children[4].value); // min.js < source
856    }
857
858    // --- Helpers ---
859
860    fn make_test_tree(n: usize) -> TrimNode {
861        let mut root = TrimNode::new(0, NodeKind::Root, 0);
862        for i in 0..n {
863            let node = TrimNode::new(i + 1, NodeKind::Item { index: i }, 10);
864            root.children.push(node);
865        }
866        root
867    }
868
869    // --- New strategies ---
870
871    #[test]
872    fn test_random_strategy_reproducible() {
873        let mut tree1 = make_test_tree(5);
874        let mut tree2 = make_test_tree(5);
875        RandomStrategy { seed: 42 }.assign_values(&mut tree1);
876        RandomStrategy { seed: 42 }.assign_values(&mut tree2);
877        // Same seed → same values
878        for (a, b) in tree1.children.iter().zip(tree2.children.iter()) {
879            assert!((a.value - b.value).abs() < 1e-9);
880        }
881    }
882
883    #[test]
884    fn test_random_strategy_different_seeds() {
885        let mut tree1 = make_test_tree(5);
886        let mut tree2 = make_test_tree(5);
887        RandomStrategy { seed: 42 }.assign_values(&mut tree1);
888        RandomStrategy { seed: 99 }.assign_values(&mut tree2);
889        // Different seeds → different values (probabilistically)
890        let same = tree1
891            .children
892            .iter()
893            .zip(tree2.children.iter())
894            .all(|(a, b)| (a.value - b.value).abs() < 1e-9);
895        assert!(!same, "Different seeds should produce different orderings");
896    }
897
898    #[test]
899    fn test_random_strategy_values_in_range() {
900        let mut tree = make_test_tree(20);
901        RandomStrategy::default().assign_values(&mut tree);
902        for child in &tree.children {
903            assert!(child.value >= 0.0 && child.value <= 1.0);
904        }
905    }
906
907    #[test]
908    fn test_reversed_strategy() {
909        let mut tree = make_test_tree(5);
910        ReversedStrategy.assign_values(&mut tree);
911        // Last item should have highest value
912        let last = tree.children.last().unwrap().value;
913        let first = tree.children.first().unwrap().value;
914        assert!(
915            last > first,
916            "Reversed: last item should have highest value"
917        );
918        assert!((last - 1.0).abs() < 0.01, "Last item should be ≈ 1.0");
919        assert!((first - 0.3).abs() < 0.1, "First item should be ≈ 0.3");
920    }
921
922    #[test]
923    fn test_reversed_is_mirror_of_element_count() {
924        let n = 10;
925        let mut tree_ec = make_test_tree(n);
926        let mut tree_rev = make_test_tree(n);
927        ElementCountStrategy.assign_values(&mut tree_ec);
928        ReversedStrategy.assign_values(&mut tree_rev);
929        // Both span [0.3, 1.0]; ElementCount decreases, Reversed increases.
930        // Verify ordering is exactly opposite.
931        for i in 0..n - 1 {
932            assert!(
933                tree_ec.children[i].value >= tree_ec.children[i + 1].value,
934                "ElementCount should be non-increasing"
935            );
936            assert!(
937                tree_rev.children[i].value <= tree_rev.children[i + 1].value,
938                "Reversed should be non-decreasing"
939            );
940        }
941        // Endpoints
942        assert!((tree_ec.children[0].value - 1.0).abs() < 0.01);
943        assert!((tree_rev.children[n - 1].value - 1.0).abs() < 0.01);
944        assert!((tree_ec.children[n - 1].value - 0.3).abs() < 0.1);
945        assert!((tree_rev.children[0].value - 0.3).abs() < 0.01);
946    }
947
948    #[test]
949    fn test_priority_strategy_fallback_to_element_count() {
950        let mut tree_ec = make_test_tree(5);
951        let mut tree_pr = make_test_tree(5);
952        ElementCountStrategy.assign_values(&mut tree_ec);
953        PriorityStrategy.assign_values(&mut tree_pr);
954        // Without metadata, Priority falls back to ElementCount
955        for (ec, pr) in tree_ec.children.iter().zip(tree_pr.children.iter()) {
956            assert!((ec.value - pr.value).abs() < 0.01);
957        }
958    }
959
960    #[test]
961    fn test_assign_priority_values_with_metadata() {
962        let mut tree = make_test_tree(3);
963        // Item 2 has highest activity and most recent — should get highest value
964        let metadata = vec![
965            ItemMetadata {
966                activity: Some(1.0),
967                days_since_update: Some(90.0),
968            },
969            ItemMetadata {
970                activity: Some(2.0),
971                days_since_update: Some(30.0),
972            },
973            ItemMetadata {
974                activity: Some(10.0),
975                days_since_update: Some(1.0),
976            },
977        ];
978        assign_priority_values(&mut tree, &metadata);
979        assert!(
980            tree.children[2].value > tree.children[0].value,
981            "Highly active recent item should score higher"
982        );
983    }
984
985    #[test]
986    fn test_priority_strategy_round_trip() {
987        assert_eq!(
988            TrimStrategyKind::parse("random"),
989            Some(TrimStrategyKind::Random)
990        );
991        assert_eq!(
992            TrimStrategyKind::parse("reversed"),
993            Some(TrimStrategyKind::Reversed)
994        );
995        assert_eq!(
996            TrimStrategyKind::parse("priority"),
997            Some(TrimStrategyKind::Priority)
998        );
999        assert_eq!(TrimStrategyKind::Random.as_str(), "random");
1000        assert_eq!(TrimStrategyKind::Reversed.as_str(), "reversed");
1001        assert_eq!(TrimStrategyKind::Priority.as_str(), "priority");
1002    }
1003
1004    #[test]
1005    fn test_hardcoded_defaults_use_priority_for_issues() {
1006        let resolver = StrategyResolver::new();
1007        assert_eq!(resolver.resolve("get_issues"), TrimStrategyKind::Priority);
1008        assert_eq!(
1009            resolver.resolve("get_merge_requests"),
1010            TrimStrategyKind::Priority
1011        );
1012        // Other tools unchanged
1013        assert_eq!(
1014            resolver.resolve("get_issue_comments"),
1015            TrimStrategyKind::Cascading
1016        );
1017    }
1018}