rag_plusplus_core/trajectory/
path_quality.rs

1//! Path Quality Computation
2//!
3//! Implements TPO (Topological Preference Optimization) path quality scoring
4//! for enhanced salience computation. Quality measures how "good" a conversation
5//! path is, which helps weight turns within that path.
6//!
7//! # Path Quality Formula
8//!
9//! ```text
10//! Q(P) = α·L(P) + β·T(P) + γ·S(P) + δ·C(P)
11//! ```
12//!
13//! | Factor | Formula | Meaning | Default Weight |
14//! |--------|---------|---------|----------------|
15//! | L(P) | 1 - var(depth_changes) | Linearity - smooth depth progression | α = 0.25 |
16//! | T(P) | terminal_node_score | Terminal quality - how path ends | β = 0.30 |
17//! | S(P) | mean(homogeneity) | Semantic coherence along path | γ = 0.25 |
18//! | C(P) | path_length / max_length | Completion - path reaches conclusion | δ = 0.20 |
19//!
20//! # Usage
21//!
22//! ```
23//! use rag_plusplus_core::trajectory::{PathQuality, PathQualityWeights, PathQualityFactors};
24//!
25//! // Compute factors for a path
26//! let factors = PathQualityFactors::from_path_data(
27//!     &[1, 2, 3, 4],           // depths along path
28//!     &[0.9, 0.85, 0.8, 0.75], // homogeneity values
29//!     5,                        // max possible depth
30//!     true,                     // is terminal
31//!     0.8,                      // terminal score if terminal
32//! );
33//!
34//! // Compute quality score with default weights
35//! let quality = factors.compute_quality(&PathQualityWeights::default());
36//!
37//! // Use for salience enhancement
38//! let base_salience = 0.5;
39//! let enhanced_salience = PathQuality::enhance_salience(base_salience, quality, 0.3);
40//! ```
41
42/// Weights for path quality components.
43///
44/// Default weights from TPO empirical analysis:
45/// - α (linearity): 0.25 - Smooth depth progression
46/// - β (terminal): 0.30 - How path ends (conclusions matter more)
47/// - γ (coherence): 0.25 - Semantic consistency
48/// - δ (completion): 0.20 - Path completeness
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct PathQualityWeights {
51    /// Weight for linearity factor (α)
52    pub alpha: f32,
53    /// Weight for terminal quality factor (β)
54    pub beta: f32,
55    /// Weight for coherence factor (γ)
56    pub gamma: f32,
57    /// Weight for completion factor (δ)
58    pub delta: f32,
59}
60
61impl Default for PathQualityWeights {
62    fn default() -> Self {
63        Self {
64            alpha: 0.25,
65            beta: 0.30,
66            gamma: 0.25,
67            delta: 0.20,
68        }
69    }
70}
71
72impl PathQualityWeights {
73    /// Create custom weights.
74    ///
75    /// Weights should sum to ~1.0 for normalized output.
76    pub fn new(alpha: f32, beta: f32, gamma: f32, delta: f32) -> Self {
77        Self { alpha, beta, gamma, delta }
78    }
79
80    /// Weights emphasizing terminal quality (for synthesis/conclusion paths).
81    pub fn terminal_focused() -> Self {
82        Self {
83            alpha: 0.15,
84            beta: 0.50,
85            gamma: 0.20,
86            delta: 0.15,
87        }
88    }
89
90    /// Weights emphasizing coherence (for focused, consistent paths).
91    pub fn coherence_focused() -> Self {
92        Self {
93            alpha: 0.20,
94            beta: 0.20,
95            gamma: 0.45,
96            delta: 0.15,
97        }
98    }
99
100    /// Weights emphasizing completion (for thorough, complete paths).
101    pub fn completion_focused() -> Self {
102        Self {
103            alpha: 0.20,
104            beta: 0.25,
105            gamma: 0.15,
106            delta: 0.40,
107        }
108    }
109}
110
111/// Computed path quality factors.
112///
113/// Each factor is in [0, 1] where 1 is best.
114#[derive(Debug, Clone, Copy, PartialEq)]
115pub struct PathQualityFactors {
116    /// L(P): Linearity - smooth depth progression
117    /// 1.0 = perfectly linear (depth increases by 1 each step)
118    /// 0.0 = highly erratic depth changes
119    pub linearity: f32,
120
121    /// T(P): Terminal quality - how well the path ends
122    /// 1.0 = terminates at a high-quality conclusion
123    /// 0.5 = non-terminal or neutral ending
124    /// 0.0 = poor ending (abandoned, error)
125    pub terminal_score: f32,
126
127    /// S(P): Semantic coherence - average homogeneity along path
128    /// 1.0 = all turns highly similar to their parents
129    /// 0.0 = no semantic continuity
130    pub coherence: f32,
131
132    /// C(P): Completion - how complete the path is
133    /// 1.0 = reached maximum expected depth
134    /// 0.0 = very short/incomplete path
135    pub completion: f32,
136}
137
138impl PathQualityFactors {
139    /// Create factors directly.
140    pub fn new(linearity: f32, terminal_score: f32, coherence: f32, completion: f32) -> Self {
141        Self {
142            linearity: linearity.clamp(0.0, 1.0),
143            terminal_score: terminal_score.clamp(0.0, 1.0),
144            coherence: coherence.clamp(0.0, 1.0),
145            completion: completion.clamp(0.0, 1.0),
146        }
147    }
148
149    /// Compute factors from path data.
150    ///
151    /// # Arguments
152    ///
153    /// * `depths` - Depth values along the path
154    /// * `homogeneities` - Homogeneity values along the path
155    /// * `max_depth` - Maximum possible depth in the trajectory
156    /// * `is_terminal` - Whether this path ends at a terminal node
157    /// * `terminal_quality` - Quality score of the terminal node [0, 1]
158    pub fn from_path_data(
159        depths: &[u32],
160        homogeneities: &[f32],
161        max_depth: u32,
162        is_terminal: bool,
163        terminal_quality: f32,
164    ) -> Self {
165        let linearity = Self::compute_linearity(depths);
166        let terminal_score = if is_terminal { terminal_quality } else { 0.5 };
167        let coherence = Self::compute_coherence(homogeneities);
168        let completion = Self::compute_completion(depths.len(), max_depth as usize);
169
170        Self::new(linearity, terminal_score, coherence, completion)
171    }
172
173    /// Compute linearity from depth sequence.
174    ///
175    /// Uses 1 - normalized_variance of depth changes.
176    fn compute_linearity(depths: &[u32]) -> f32 {
177        if depths.len() < 2 {
178            return 1.0; // Single node is perfectly "linear"
179        }
180
181        // Compute depth changes
182        let changes: Vec<i32> = depths.windows(2)
183            .map(|w| w[1] as i32 - w[0] as i32)
184            .collect();
185
186        // Compute variance of changes
187        let n = changes.len() as f32;
188        let mean: f32 = changes.iter().map(|&c| c as f32).sum::<f32>() / n;
189        let variance: f32 = changes.iter()
190            .map(|&c| (c as f32 - mean).powi(2))
191            .sum::<f32>() / n;
192
193        // Ideal change is 1 (going one level deeper each time)
194        // Variance of 0 means perfectly linear
195        // Normalize: variance of 1 is "bad", variance of 0 is "good"
196        // Max expected variance for erratic paths is ~4-5
197        (1.0 - variance / 5.0).clamp(0.0, 1.0)
198    }
199
200    /// Compute coherence from homogeneity sequence.
201    fn compute_coherence(homogeneities: &[f32]) -> f32 {
202        if homogeneities.is_empty() {
203            return 0.5; // Neutral for empty
204        }
205
206        let sum: f32 = homogeneities.iter().sum();
207        sum / homogeneities.len() as f32
208    }
209
210    /// Compute completion from path length vs max depth.
211    fn compute_completion(path_length: usize, max_depth: usize) -> f32 {
212        if max_depth == 0 {
213            return 1.0; // Single-node trajectory is complete
214        }
215
216        // A path of max_depth + 1 nodes is fully complete
217        let expected_length = max_depth + 1;
218        (path_length as f32 / expected_length as f32).min(1.0)
219    }
220
221    /// Compute weighted quality score.
222    ///
223    /// Returns value in [0, 1] (approximately, depending on weights).
224    #[inline]
225    pub fn compute_quality(&self, weights: &PathQualityWeights) -> f32 {
226        weights.alpha * self.linearity
227            + weights.beta * self.terminal_score
228            + weights.gamma * self.coherence
229            + weights.delta * self.completion
230    }
231
232    /// Compute quality with default weights.
233    #[inline]
234    pub fn quality(&self) -> f32 {
235        self.compute_quality(&PathQualityWeights::default())
236    }
237
238    /// Check if this is a high-quality path (quality > 0.7).
239    #[inline]
240    pub fn is_high_quality(&self) -> bool {
241        self.quality() > 0.7
242    }
243
244    /// Check if this is a low-quality path (quality < 0.4).
245    #[inline]
246    pub fn is_low_quality(&self) -> bool {
247        self.quality() < 0.4
248    }
249}
250
251impl Default for PathQualityFactors {
252    /// Default neutral quality factors.
253    fn default() -> Self {
254        Self {
255            linearity: 0.5,
256            terminal_score: 0.5,
257            coherence: 0.5,
258            completion: 0.5,
259        }
260    }
261}
262
263/// Path quality utilities.
264pub struct PathQuality;
265
266impl PathQuality {
267    /// Enhance a base salience score using path quality.
268    ///
269    /// # Arguments
270    ///
271    /// * `base_salience` - Original salience score [0, 1]
272    /// * `quality` - Path quality score [0, 1]
273    /// * `blend` - How much quality affects salience [0, 1]
274    ///   - 0.0 = quality has no effect
275    ///   - 1.0 = quality fully replaces base salience
276    ///
277    /// # Returns
278    ///
279    /// Enhanced salience in [0, 1].
280    #[inline]
281    pub fn enhance_salience(base_salience: f32, quality: f32, blend: f32) -> f32 {
282        let blend = blend.clamp(0.0, 1.0);
283        (1.0 - blend) * base_salience + blend * quality
284    }
285
286    /// Boost terminal nodes based on path quality.
287    ///
288    /// Terminal nodes in high-quality paths get boosted more.
289    #[inline]
290    pub fn terminal_boost(base_salience: f32, quality: f32, is_terminal: bool) -> f32 {
291        if is_terminal {
292            // Terminal nodes get up to 50% boost based on quality
293            base_salience + 0.5 * quality * (1.0 - base_salience)
294        } else {
295            base_salience
296        }
297    }
298
299    /// Compute path quality for a sequence of episodes.
300    ///
301    /// # Arguments
302    ///
303    /// * `depths` - Depth of each episode
304    /// * `homogeneities` - Homogeneity (semantic similarity to parent) of each episode
305    /// * `max_depth` - Maximum depth in the full trajectory
306    /// * `is_terminal` - Whether the last episode is a terminal node
307    /// * `terminal_quality` - Quality score for terminal (use feedback if available)
308    pub fn compute(
309        depths: &[u32],
310        homogeneities: &[f32],
311        max_depth: u32,
312        is_terminal: bool,
313        terminal_quality: f32,
314    ) -> PathQualityFactors {
315        PathQualityFactors::from_path_data(
316            depths,
317            homogeneities,
318            max_depth,
319            is_terminal,
320            terminal_quality,
321        )
322    }
323
324    /// Estimate terminal quality from phase.
325    ///
326    /// Synthesis and Planning phases typically end better than Debugging.
327    pub fn terminal_quality_from_phase(phase: Option<&str>) -> f32 {
328        match phase {
329            Some("synthesis") => 0.9,    // Conclusions are high quality
330            Some("planning") => 0.85,    // Plans are good endings
331            Some("consolidation") => 0.7, // Building understanding
332            Some("exploration") => 0.5,  // Neutral - still exploring
333            Some("debugging") => 0.4,    // Often indicates problems
334            None => 0.5,                 // Unknown phase
335            _ => 0.5,
336        }
337    }
338
339    /// Estimate terminal quality from feedback.
340    pub fn terminal_quality_from_feedback(has_thumbs_up: bool, has_thumbs_down: bool) -> f32 {
341        if has_thumbs_up {
342            0.95
343        } else if has_thumbs_down {
344            0.1
345        } else {
346            0.5
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn test_default_weights() {
357        let w = PathQualityWeights::default();
358        let sum = w.alpha + w.beta + w.gamma + w.delta;
359        assert!((sum - 1.0).abs() < 1e-6);
360    }
361
362    #[test]
363    fn test_terminal_focused_weights() {
364        let w = PathQualityWeights::terminal_focused();
365        assert!(w.beta > w.alpha);
366        assert!(w.beta > w.gamma);
367        assert!(w.beta > w.delta);
368    }
369
370    #[test]
371    fn test_factors_new() {
372        let f = PathQualityFactors::new(0.8, 0.7, 0.6, 0.5);
373        assert!((f.linearity - 0.8).abs() < 1e-6);
374        assert!((f.terminal_score - 0.7).abs() < 1e-6);
375        assert!((f.coherence - 0.6).abs() < 1e-6);
376        assert!((f.completion - 0.5).abs() < 1e-6);
377    }
378
379    #[test]
380    fn test_factors_clamped() {
381        let f = PathQualityFactors::new(1.5, -0.5, 0.5, 0.5);
382        assert!((f.linearity - 1.0).abs() < 1e-6);
383        assert!((f.terminal_score - 0.0).abs() < 1e-6);
384    }
385
386    #[test]
387    fn test_compute_linearity_perfect() {
388        // Perfect linear progression: 0, 1, 2, 3, 4
389        let depths = vec![0, 1, 2, 3, 4];
390        let linearity = PathQualityFactors::compute_linearity(&depths);
391        // All changes are +1, variance = 0, so linearity = 1.0
392        assert!((linearity - 1.0).abs() < 0.01);
393    }
394
395    #[test]
396    fn test_compute_linearity_erratic() {
397        // Erratic: 0, 5, 1, 4, 2
398        let depths = vec![0, 5, 1, 4, 2];
399        let linearity = PathQualityFactors::compute_linearity(&depths);
400        // High variance, so linearity should be low
401        assert!(linearity < 0.5);
402    }
403
404    #[test]
405    fn test_compute_coherence() {
406        let homogeneities = vec![0.9, 0.8, 0.7, 0.6];
407        let coherence = PathQualityFactors::compute_coherence(&homogeneities);
408        // Mean = (0.9 + 0.8 + 0.7 + 0.6) / 4 = 0.75
409        assert!((coherence - 0.75).abs() < 1e-6);
410    }
411
412    #[test]
413    fn test_compute_completion() {
414        // Path length 5, max depth 4 (so full path would be 5 nodes)
415        let completion = PathQualityFactors::compute_completion(5, 4);
416        assert!((completion - 1.0).abs() < 1e-6);
417
418        // Path length 3, max depth 4 (3/5 = 0.6)
419        let completion = PathQualityFactors::compute_completion(3, 4);
420        assert!((completion - 0.6).abs() < 1e-6);
421    }
422
423    #[test]
424    fn test_from_path_data() {
425        let depths = vec![0, 1, 2, 3];
426        let homogeneities = vec![1.0, 0.9, 0.8, 0.7];
427        let max_depth = 5;
428
429        let factors = PathQualityFactors::from_path_data(
430            &depths,
431            &homogeneities,
432            max_depth,
433            true,
434            0.9,
435        );
436
437        // Linearity should be high (linear progression)
438        assert!(factors.linearity > 0.9);
439
440        // Terminal score is 0.9 (as given)
441        assert!((factors.terminal_score - 0.9).abs() < 1e-6);
442
443        // Coherence = mean of homogeneities = 0.85
444        assert!((factors.coherence - 0.85).abs() < 1e-6);
445
446        // Completion = 4 / 6 ≈ 0.67
447        assert!(factors.completion > 0.6);
448        assert!(factors.completion < 0.7);
449    }
450
451    #[test]
452    fn test_compute_quality() {
453        let factors = PathQualityFactors::new(0.8, 0.9, 0.7, 0.6);
454        let weights = PathQualityWeights::default();
455
456        // Q = 0.25*0.8 + 0.30*0.9 + 0.25*0.7 + 0.20*0.6
457        //   = 0.2 + 0.27 + 0.175 + 0.12 = 0.765
458        let quality = factors.compute_quality(&weights);
459        assert!((quality - 0.765).abs() < 1e-5);
460    }
461
462    #[test]
463    fn test_is_high_quality() {
464        let high = PathQualityFactors::new(0.9, 0.9, 0.9, 0.9);
465        let low = PathQualityFactors::new(0.2, 0.2, 0.2, 0.2);
466
467        assert!(high.is_high_quality());
468        assert!(!low.is_high_quality());
469        assert!(low.is_low_quality());
470        assert!(!high.is_low_quality());
471    }
472
473    #[test]
474    fn test_enhance_salience() {
475        let base = 0.5;
476        let quality = 0.8;
477
478        // No blend - original salience
479        assert!((PathQuality::enhance_salience(base, quality, 0.0) - 0.5).abs() < 1e-6);
480
481        // Full blend - quality replaces salience
482        assert!((PathQuality::enhance_salience(base, quality, 1.0) - 0.8).abs() < 1e-6);
483
484        // 50% blend
485        assert!((PathQuality::enhance_salience(base, quality, 0.5) - 0.65).abs() < 1e-6);
486    }
487
488    #[test]
489    fn test_terminal_boost() {
490        let base = 0.6;
491        let quality = 0.8;
492
493        // Non-terminal - no boost
494        let non_terminal = PathQuality::terminal_boost(base, quality, false);
495        assert!((non_terminal - 0.6).abs() < 1e-6);
496
497        // Terminal - gets boosted
498        let terminal = PathQuality::terminal_boost(base, quality, true);
499        // boost = 0.5 * 0.8 * 0.4 = 0.16
500        // result = 0.6 + 0.16 = 0.76
501        assert!((terminal - 0.76).abs() < 1e-6);
502    }
503
504    #[test]
505    fn test_terminal_quality_from_phase() {
506        assert!(PathQuality::terminal_quality_from_phase(Some("synthesis")) > 0.8);
507        assert!(PathQuality::terminal_quality_from_phase(Some("debugging")) < 0.5);
508        assert!((PathQuality::terminal_quality_from_phase(None) - 0.5).abs() < 1e-6);
509    }
510
511    #[test]
512    fn test_terminal_quality_from_feedback() {
513        assert!(PathQuality::terminal_quality_from_feedback(true, false) > 0.9);
514        assert!(PathQuality::terminal_quality_from_feedback(false, true) < 0.2);
515        assert!((PathQuality::terminal_quality_from_feedback(false, false) - 0.5).abs() < 1e-6);
516    }
517}