Skip to main content

cel_context/
resolve.rs

1//! Reference resolution — find elements by multi-signal matching.
2
3use crate::element::{ContextElement, ContextReference, ScreenContext};
4
5/// Minimum score required for a match to be returned.
6const MATCH_THRESHOLD: f64 = 0.55;
7
8/// Weight for element type match (required — if this fails, score is 0).
9const W_TYPE: f64 = 0.30;
10/// Weight for label match (fuzzy).
11const W_LABEL: f64 = 0.30;
12/// Weight for ancestor path prefix match.
13const W_ANCESTOR: f64 = 0.20;
14/// Weight for bounds region proximity.
15const W_BOUNDS: f64 = 0.10;
16/// Weight for value pattern match.
17const W_VALUE: f64 = 0.10;
18
19/// Resolve a reference against a screen context snapshot.
20/// Returns the best-matching element if its score exceeds the threshold.
21pub fn resolve_reference<'a>(
22    context: &'a ScreenContext,
23    reference: &ContextReference,
24) -> Option<&'a ContextElement> {
25    let mut best: Option<(&ContextElement, f64)> = None;
26
27    for el in &context.elements {
28        let score = score_element(el, reference, context);
29        if score >= MATCH_THRESHOLD && (best.is_none() || score > best.unwrap().1) {
30            best = Some((el, score));
31        }
32    }
33
34    best.map(|(el, _)| el)
35}
36
37/// Build the ancestor path for an element by following parent_id chains.
38/// Returns a list of element_types from root to parent (not including the element itself).
39fn build_ancestor_path(el: &ContextElement, all_elements: &[ContextElement]) -> Vec<String> {
40    let mut path = Vec::new();
41    let mut current_id = el.parent_id.as_deref();
42    // Limit traversal depth to avoid infinite loops from bad data
43    let mut depth = 0;
44    while let Some(pid) = current_id {
45        if depth > 15 {
46            break;
47        }
48        if let Some(parent) = all_elements.iter().find(|e| e.id == pid) {
49            path.push(parent.element_type.clone());
50            current_id = parent.parent_id.as_deref();
51        } else {
52            break;
53        }
54        depth += 1;
55    }
56    path.reverse(); // Root → parent order
57    path
58}
59
60fn score_element(
61    el: &ContextElement,
62    reference: &ContextReference,
63    context: &ScreenContext,
64) -> f64 {
65    // Type must match exactly — it's a hard requirement.
66    if el.element_type != reference.element_type {
67        return 0.0;
68    }
69    let mut score = W_TYPE;
70
71    // Label matching (case-insensitive contains)
72    match (&el.label, &reference.label) {
73        (Some(el_label), Some(ref_label)) => {
74            let el_lower = el_label.to_lowercase();
75            let ref_lower = ref_label.to_lowercase();
76            if el_lower == ref_lower {
77                score += W_LABEL;
78            } else if el_lower.contains(&ref_lower) || ref_lower.contains(&el_lower) {
79                score += W_LABEL * 0.8;
80            }
81        }
82        (None, None) => {
83            // Both have no label — slight positive signal
84            score += W_LABEL * 0.3;
85        }
86        _ => {
87            // One has label, other doesn't — no match
88        }
89    }
90
91    // Ancestor path prefix match
92    if !reference.ancestor_path.is_empty() {
93        let el_path = build_ancestor_path(el, &context.elements);
94        if !el_path.is_empty() {
95            let match_count = reference
96                .ancestor_path
97                .iter()
98                .zip(el_path.iter())
99                .filter(|(a, b)| a == b)
100                .count();
101            let ref_len = reference.ancestor_path.len();
102            if match_count == ref_len {
103                score += W_ANCESTOR;
104            } else if ref_len > 0 && match_count as f64 / ref_len as f64 >= 0.5 {
105                score += W_ANCESTOR * 0.5;
106            }
107        }
108    }
109
110    // Bounds region proximity
111    if let (Some(el_bounds), Some(ref_region)) = (&el.bounds, &reference.bounds_region) {
112        let el_cx = el_bounds.x as f64 + el_bounds.width as f64 / 2.0;
113        let el_cy = el_bounds.y as f64 + el_bounds.height as f64 / 2.0;
114
115        let sw = context.screen_width.unwrap_or(1920) as f64;
116        let sh = context.screen_height.unwrap_or(1080) as f64;
117        let el_rx = (el_cx / sw).clamp(0.0, 1.0);
118        let el_ry = (el_cy / sh).clamp(0.0, 1.0);
119
120        let dist = ((el_rx - ref_region.relative_x).powi(2)
121            + (el_ry - ref_region.relative_y).powi(2))
122        .sqrt();
123
124        // Close = high score, far = low score
125        if dist < 0.1 {
126            score += W_BOUNDS;
127        } else if dist < 0.3 {
128            score += W_BOUNDS * 0.5;
129        }
130    }
131
132    // Value pattern match
133    match (&el.value, &reference.value_pattern) {
134        (Some(el_val), Some(pattern)) => {
135            if el_val == pattern {
136                score += W_VALUE;
137            } else if el_val.contains(pattern) {
138                score += W_VALUE * 0.5;
139            }
140        }
141        (None, None) => {
142            score += W_VALUE * 0.3;
143        }
144        _ => {}
145    }
146
147    score
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::element::{ContentRole, ContextSource};
154    use crate::{Bounds, ElementState};
155
156    fn make_element(id: &str, etype: &str, label: Option<&str>) -> ContextElement {
157        ContextElement {
158            id: id.to_string(),
159            label: label.map(|s| s.to_string()),
160            description: None,
161            element_type: etype.to_string(),
162            value: None,
163            bounds: Some(Bounds {
164                x: 100,
165                y: 200,
166                width: 80,
167                height: 30,
168            }),
169            state: ElementState::default(),
170            parent_id: None,
171            actions: vec![],
172            confidence: 0.85,
173            source: ContextSource::AccessibilityTree,
174            properties: std::collections::HashMap::new(),
175            content_role: ContentRole::default(),
176        }
177    }
178
179    #[test]
180    fn test_exact_match() {
181        let ctx = ScreenContext {
182            app: "Test".into(),
183            window: "Test Window".into(),
184            elements: vec![
185                make_element("1", "button", Some("Submit")),
186                make_element("2", "input", Some("Email")),
187            ],
188            network_events: vec![],
189            http_events: vec![],
190            timestamp_ms: 0,
191            screen_width: None,
192            screen_height: None,
193            clipboard: None,
194            window_list: vec![],
195            audio: None,
196            power: None,
197            running_apps: vec![],
198            recent_files: vec![],
199            transcripts: vec![],
200        };
201
202        let reference = ContextReference {
203            element_type: "button".into(),
204            label: Some("Submit".into()),
205            ancestor_path: vec![],
206            bounds_region: None,
207            value_pattern: None,
208        };
209
210        let result = resolve_reference(&ctx, &reference);
211        assert!(result.is_some());
212        assert_eq!(result.unwrap().id, "1");
213    }
214
215    #[test]
216    fn test_no_match_wrong_type() {
217        let ctx = ScreenContext {
218            app: "Test".into(),
219            window: "Test Window".into(),
220            elements: vec![make_element("1", "button", Some("Submit"))],
221            network_events: vec![],
222            http_events: vec![],
223            timestamp_ms: 0,
224            screen_width: None,
225            screen_height: None,
226            clipboard: None,
227            window_list: vec![],
228            audio: None,
229            power: None,
230            running_apps: vec![],
231            recent_files: vec![],
232            transcripts: vec![],
233        };
234
235        let reference = ContextReference {
236            element_type: "input".into(),
237            label: Some("Submit".into()),
238            ancestor_path: vec![],
239            bounds_region: None,
240            value_pattern: None,
241        };
242
243        let result = resolve_reference(&ctx, &reference);
244        assert!(result.is_none());
245    }
246
247    #[test]
248    fn test_fuzzy_label_match() {
249        let ctx = ScreenContext {
250            app: "Test".into(),
251            window: "Test Window".into(),
252            elements: vec![make_element("1", "button", Some("Submit Form"))],
253            network_events: vec![],
254            http_events: vec![],
255            timestamp_ms: 0,
256            screen_width: None,
257            screen_height: None,
258            clipboard: None,
259            window_list: vec![],
260            audio: None,
261            power: None,
262            running_apps: vec![],
263            recent_files: vec![],
264            transcripts: vec![],
265        };
266
267        let reference = ContextReference {
268            element_type: "button".into(),
269            label: Some("Submit".into()),
270            ancestor_path: vec![],
271            bounds_region: None,
272            value_pattern: None,
273        };
274
275        let result = resolve_reference(&ctx, &reference);
276        assert!(result.is_some());
277    }
278}