Skip to main content

langextract_rust/
alignment.rs

1//! Text alignment functionality for mapping extractions to source text positions.
2//!
3//! This module provides algorithms to align extracted text with their positions
4//! in the original source text, supporting both exact and fuzzy matching.
5
6use crate::{
7    data::{AlignmentStatus, CharInterval, Extraction},
8    exceptions::LangExtractResult,
9};
10use std::cmp::min;
11
12/// Configuration for text alignment
13#[derive(Debug, Clone)]
14pub struct AlignmentConfig {
15    /// Enable fuzzy alignment when exact matching fails
16    pub enable_fuzzy_alignment: bool,
17    /// Minimum overlap ratio for fuzzy alignment (0.0 to 1.0)
18    pub fuzzy_alignment_threshold: f32,
19    /// Accept partial exact matches (MATCH_LESSER status)
20    pub accept_match_lesser: bool,
21    /// Case-sensitive matching
22    pub case_sensitive: bool,
23    /// Maximum search window size for fuzzy matching
24    pub max_search_window: usize,
25}
26
27impl Default for AlignmentConfig {
28    fn default() -> Self {
29        Self {
30            enable_fuzzy_alignment: true,
31            fuzzy_alignment_threshold: 0.4, // Lower threshold for better fuzzy matching
32            accept_match_lesser: true,
33            case_sensitive: false,
34            max_search_window: 100,
35        }
36    }
37}
38
39/// Text aligner for mapping extractions to source text positions
40pub struct TextAligner {
41    config: AlignmentConfig,
42}
43
44impl TextAligner {
45    /// Create a new text aligner with default configuration
46    pub fn new() -> Self {
47        Self {
48            config: AlignmentConfig::default(),
49        }
50    }
51
52    /// Create a new text aligner with custom configuration
53    pub fn with_config(config: AlignmentConfig) -> Self {
54        Self { config }
55    }
56
57    /// Align extractions with the source text.
58    /// Pre-lowercases the source text once and reuses it across all extractions
59    /// to avoid repeated O(n) allocations.
60    #[tracing::instrument(skip_all, fields(num_extractions = extractions.len(), source_len = source_text.len(), char_offset))]
61    pub fn align_extractions(
62        &self,
63        extractions: &mut [Extraction],
64        source_text: &str,
65        char_offset: usize,
66    ) -> LangExtractResult<usize> {
67        // Pre-lowercase the source text once for all extractions (issue 5.3 fix)
68        let search_text = if self.config.case_sensitive {
69            source_text.to_string()
70        } else {
71            source_text.to_lowercase()
72        };
73
74        // Pre-compute source words and their byte offsets for fuzzy matching reuse
75        let source_words: Vec<&str> = search_text.split_whitespace().collect();
76        let word_byte_offsets: Vec<(usize, usize)> = search_text
77            .split_whitespace()
78            .map(|word| {
79                let start = word.as_ptr() as usize - search_text.as_ptr() as usize;
80                (start, start + word.len())
81            })
82            .collect();
83
84        let mut aligned_count = 0;
85        for extraction in extractions.iter_mut() {
86            if let Some(interval) = self.align_single_extraction_with_cache(
87                extraction, &search_text, &source_words, &word_byte_offsets, char_offset,
88            )? {
89                extraction.char_interval = Some(interval);
90                aligned_count += 1;
91            }
92        }
93
94        Ok(aligned_count)
95    }
96
97    /// Align a single extraction using pre-computed lowercase source text and word list.
98    fn align_single_extraction_with_cache(
99        &self,
100        extraction: &mut Extraction,
101        search_text: &str,
102        source_words: &[&str],
103        word_byte_offsets: &[(usize, usize)],
104        char_offset: usize,
105    ) -> LangExtractResult<Option<CharInterval>> {
106        let extraction_text = if self.config.case_sensitive {
107            extraction.extraction_text.clone()
108        } else {
109            extraction.extraction_text.to_lowercase()
110        };
111
112        // Try exact matching first
113        if let Some((start, end, status)) = self.find_exact_match(&extraction_text, search_text) {
114            extraction.alignment_status = Some(status);
115            return Ok(Some(CharInterval::new(
116                Some(start + char_offset),
117                Some(end + char_offset),
118            )));
119        }
120
121        // Try fuzzy matching if enabled (reuse pre-computed source_words)
122        if self.config.enable_fuzzy_alignment {
123            if let Some((start, end, status)) = self.find_fuzzy_match_with_words(&extraction_text, search_text, source_words, word_byte_offsets) {
124                extraction.alignment_status = Some(status);
125                return Ok(Some(CharInterval::new(
126                    Some(start + char_offset),
127                    Some(end + char_offset),
128                )));
129            }
130        }
131
132        // No alignment found
133        extraction.alignment_status = None;
134        Ok(None)
135    }
136
137    /// Align a single extraction with the source text (public API, lowercases on each call)
138    pub fn align_single_extraction(
139        &self,
140        extraction: &mut Extraction,
141        source_text: &str,
142        char_offset: usize,
143    ) -> LangExtractResult<Option<CharInterval>> {
144        let search_text = if self.config.case_sensitive {
145            source_text.to_string()
146        } else {
147            source_text.to_lowercase()
148        };
149        let source_words: Vec<&str> = search_text.split_whitespace().collect();
150        let word_byte_offsets: Vec<(usize, usize)> = search_text
151            .split_whitespace()
152            .map(|word| {
153                let start = word.as_ptr() as usize - search_text.as_ptr() as usize;
154                (start, start + word.len())
155            })
156            .collect();
157
158        self.align_single_extraction_with_cache(extraction, &search_text, &source_words, &word_byte_offsets, char_offset)
159    }
160
161    /// Find exact matches in the source text
162    fn find_exact_match(&self, extraction_text: &str, source_text: &str) -> Option<(usize, usize, AlignmentStatus)> {
163        // Try to find the exact extraction text
164        if let Some(start) = source_text.find(extraction_text) {
165            let end = start + extraction_text.len();
166            return Some((start, end, AlignmentStatus::MatchExact));
167        }
168
169        // Try to find the extraction text as a substring (MATCH_LESSER)
170        if self.config.accept_match_lesser {
171            // Look for words from the extraction text
172            let extraction_words: Vec<&str> = extraction_text.split_whitespace().collect();
173            if extraction_words.len() > 1 {
174                // Try to find the first and last words
175                if let (Some(first_word), Some(last_word)) = (extraction_words.first(), extraction_words.last()) {
176                    if let Some(first_start) = source_text.find(first_word) {
177                        if let Some(last_start) = source_text[first_start..].find(last_word) {
178                            let last_absolute_start = first_start + last_start;
179                            let last_end = last_absolute_start + last_word.len();
180                            
181                            // Check if this span is reasonable (not too long)
182                            if last_end - first_start < extraction_text.len() * 2 {
183                                return Some((first_start, last_end, AlignmentStatus::MatchLesser));
184                            }
185                        }
186                    }
187                }
188            }
189        }
190
191        None
192    }
193
194    /// Find fuzzy matches using pre-computed source word list and byte offsets.
195    /// Avoids re-splitting, re-lowercasing, and allocating join strings per extraction.
196    fn find_fuzzy_match_with_words(&self, extraction_text: &str, source_text: &str, source_words: &[&str], word_byte_offsets: &[(usize, usize)]) -> Option<(usize, usize, AlignmentStatus)> {
197        let extraction_words: Vec<&str> = extraction_text.split_whitespace().collect();
198
199        if extraction_words.is_empty() || source_words.is_empty() {
200            return None;
201        }
202
203        let mut best_match: Option<(usize, usize, f32)> = None;
204        
205        // Try different window sizes, starting with a reasonable size
206        let max_window = min(source_words.len(), self.config.max_search_window);
207        let min_window = extraction_words.len();
208        
209        for window_size in min_window..=max_window {
210            for start_idx in 0..=source_words.len().saturating_sub(window_size) {
211                let end_idx = start_idx + window_size;
212                let window = &source_words[start_idx..end_idx];
213
214                // Words are already lowercased (from pre-computation), use direct comparison
215                let similarity = self.calculate_word_similarity_direct(&extraction_words, window);
216                
217                if similarity >= self.config.fuzzy_alignment_threshold {
218                    if let Some((_, _, current_best)) = best_match {
219                        if similarity > current_best {
220                            best_match = Some((start_idx, end_idx, similarity));
221                        }
222                    } else {
223                        best_match = Some((start_idx, end_idx, similarity));
224                    }
225                }
226            }
227            
228            // If we found a good match with a smaller window, prefer it
229            if best_match.is_some() {
230                break;
231            }
232        }
233
234        // Convert word positions back to character positions using pre-computed byte offsets
235        if let Some((start_word_idx, end_word_idx, _)) = best_match {
236            let char_start = word_byte_offsets[start_word_idx].0;
237            let char_end = if end_word_idx >= source_words.len() {
238                source_text.len()
239            } else {
240                word_byte_offsets[end_word_idx - 1].1
241            };
242
243            return Some((char_start, char_end, AlignmentStatus::MatchFuzzy));
244        }
245
246        None
247    }
248
249    /// Calculate similarity between two pre-lowercased word sequences.
250    /// Uses direct comparison (no re-lowercasing) since input words are
251    /// already normalized.
252    fn calculate_word_similarity_direct(&self, words1: &[&str], words2: &[&str]) -> f32 {
253        if words1.is_empty() && words2.is_empty() {
254            return 1.0;
255        }
256        if words1.is_empty() || words2.is_empty() {
257            return 0.0;
258        }
259
260        // Build HashSet for O(1) lookup instead of Vec::contains O(n)
261        let word_set2: std::collections::HashSet<&str> = words2.iter().copied().collect();
262
263        // Count how many words from extraction are found in the source window
264        let found_count = words1.iter().filter(|w| word_set2.contains(**w)).count();
265
266        // Calculate coverage: what percentage of extraction words are found
267        found_count as f32 / words1.len() as f32
268    }
269
270    /// Align extractions for chunked text processing
271    pub fn align_chunk_extractions(
272        &self,
273        extractions: &mut [Extraction],
274        chunk_text: &str,
275        chunk_char_offset: usize,
276    ) -> LangExtractResult<usize> {
277        self.align_extractions(extractions, chunk_text, chunk_char_offset)
278    }
279
280    /// Get alignment statistics
281    pub fn get_alignment_stats(&self, extractions: &[Extraction]) -> AlignmentStats {
282        let total = extractions.len();
283        let mut exact = 0;
284        let mut fuzzy = 0;
285        let mut lesser = 0;
286        let mut greater = 0;
287        let mut unaligned = 0;
288
289        for extraction in extractions {
290            match extraction.alignment_status {
291                Some(AlignmentStatus::MatchExact) => exact += 1,
292                Some(AlignmentStatus::MatchFuzzy) => fuzzy += 1,
293                Some(AlignmentStatus::MatchLesser) => lesser += 1,
294                Some(AlignmentStatus::MatchGreater) => greater += 1,
295                None => unaligned += 1,
296            }
297        }
298
299        AlignmentStats {
300            total,
301            exact,
302            fuzzy,
303            lesser,
304            greater,
305            unaligned,
306        }
307    }
308}
309
310impl Default for TextAligner {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316/// Statistics about alignment results
317#[derive(Debug, Clone)]
318pub struct AlignmentStats {
319    pub total: usize,
320    pub exact: usize,
321    pub fuzzy: usize,
322    pub lesser: usize,
323    pub greater: usize,
324    pub unaligned: usize,
325}
326
327impl AlignmentStats {
328    /// Get the alignment success rate (0.0 to 1.0)
329    pub fn success_rate(&self) -> f32 {
330        if self.total == 0 {
331            1.0
332        } else {
333            (self.total - self.unaligned) as f32 / self.total as f32
334        }
335    }
336
337    /// Get the exact match rate (0.0 to 1.0)
338    pub fn exact_match_rate(&self) -> f32 {
339        if self.total == 0 {
340            0.0
341        } else {
342            self.exact as f32 / self.total as f32
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_exact_alignment() {
353        let aligner = TextAligner::new();
354        let mut extraction = Extraction::new("person".to_string(), "John Doe".to_string());
355        let source_text = "Hello, John Doe is a software engineer.";
356
357        let result = aligner.align_single_extraction(&mut extraction, source_text, 0).unwrap();
358
359        assert!(result.is_some());
360        let interval = result.unwrap();
361        assert_eq!(interval.start_pos, Some(7));
362        assert_eq!(interval.end_pos, Some(15));
363        assert_eq!(extraction.alignment_status, Some(AlignmentStatus::MatchExact));
364    }
365
366    #[test]
367    fn test_case_insensitive_alignment() {
368        let aligner = TextAligner::new();
369        let mut extraction = Extraction::new("person".to_string(), "JOHN DOE".to_string());
370        let source_text = "Hello, john doe is a software engineer.";
371
372        let result = aligner.align_single_extraction(&mut extraction, source_text, 0).unwrap();
373
374        assert!(result.is_some());
375        let interval = result.unwrap();
376        assert_eq!(interval.start_pos, Some(7));
377        assert_eq!(interval.end_pos, Some(15));
378        assert_eq!(extraction.alignment_status, Some(AlignmentStatus::MatchExact));
379    }
380
381    #[test]
382    fn test_fuzzy_alignment() {
383        let aligner = TextAligner::new();
384        let mut extraction = Extraction::new("person".to_string(), "John Smith".to_string());
385        let source_text = "Hello, John is a software engineer named Smith.";
386
387        let result = aligner.align_single_extraction(&mut extraction, source_text, 0).unwrap();
388
389        assert!(result.is_some());
390        assert_eq!(extraction.alignment_status, Some(AlignmentStatus::MatchFuzzy));
391    }
392
393    #[test]
394    fn test_no_alignment() {
395        let aligner = TextAligner::new();
396        let mut extraction = Extraction::new("person".to_string(), "Jane Doe".to_string());
397        let source_text = "Hello, John Smith is a software engineer.";
398
399        let result = aligner.align_single_extraction(&mut extraction, source_text, 0).unwrap();
400
401        assert!(result.is_none());
402        assert_eq!(extraction.alignment_status, None);
403    }
404
405    #[test]
406    fn test_chunk_offset() {
407        let aligner = TextAligner::new();
408        let mut extraction = Extraction::new("person".to_string(), "John Doe".to_string());
409        let chunk_text = "John Doe is here.";
410        let chunk_offset = 100;
411
412        let result = aligner.align_single_extraction(&mut extraction, chunk_text, chunk_offset).unwrap();
413
414        assert!(result.is_some());
415        let interval = result.unwrap();
416        assert_eq!(interval.start_pos, Some(100)); // 0 + 100
417        assert_eq!(interval.end_pos, Some(108));   // 8 + 100
418    }
419
420    #[test]
421    fn test_alignment_stats() {
422        let aligner = TextAligner::new();
423        let extractions = vec![
424            Extraction {
425                extraction_class: "test".to_string(),
426                extraction_text: "test".to_string(),
427                alignment_status: Some(AlignmentStatus::MatchExact),
428                ..Default::default()
429            },
430            Extraction {
431                extraction_class: "test".to_string(),
432                extraction_text: "test".to_string(),
433                alignment_status: Some(AlignmentStatus::MatchFuzzy),
434                ..Default::default()
435            },
436            Extraction {
437                extraction_class: "test".to_string(),
438                extraction_text: "test".to_string(),
439                alignment_status: None,
440                ..Default::default()
441            },
442        ];
443
444        let stats = aligner.get_alignment_stats(&extractions);
445        assert_eq!(stats.total, 3);
446        assert_eq!(stats.exact, 1);
447        assert_eq!(stats.fuzzy, 1);
448        assert_eq!(stats.unaligned, 1);
449        assert_eq!(stats.success_rate(), 2.0 / 3.0);
450        assert_eq!(stats.exact_match_rate(), 1.0 / 3.0);
451    }
452}