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}