//! Adaptive @ref relocation. //! //! When a saved `@ref`'s DOM node is gone (stale `backendNodeId`) and the //! role/name/nth re-query also fails, we score the current page's candidate //! elements against the ref's stored [`ElementFingerprint`] and relocate to the //! best match — but ONLY when confident: the best candidate must clear a high //! absolute threshold AND beat the runner-up by a clear margin. This matches the //! project's "fail loudly rather than mis-click" posture (see the identity and //! occlusion guards in `element.rs`). //! //! Everything in this module is pure and browser-free so the scoring can be //! unit-tested directly. use std::collections::BTreeMap; /// Minimum absolute similarity (0..1) for a relocation candidate to be accepted. pub const ADAPTIVE_THRESHOLD: f64 = 0.70; /// Minimum gap between the best and second-best candidate to avoid ambiguity. pub const ADAPTIVE_MARGIN: f64 = 0.15; /// A structural/semantic fingerprint of an element, captured at snapshot time so /// a moved element can be re-identified after the page mutates. /// /// Populated purely from the accessibility tree we already walk (`TreeNode`), so /// capturing it costs no extra CDP round-trips — `TreeNode` has no DOM tag or /// attributes (those would need an N×`DOM.describeNode` storm per snapshot), so /// `tag` holds the AX **role** and `attrs` holds discriminating AX properties /// (value/url/level/checked), not DOM `id`/`class`. #[derive(Debug, Clone, Default, PartialEq)] pub struct ElementFingerprint { /// AX role, e.g. "button" (used where a DOM tag would otherwise go). pub tag: String, /// Accessible name / visible text — the dominant identity signal. pub text: String, /// Discriminating AX properties: value, url, level, checked. Keyed by name. pub attrs: BTreeMap, /// Ancestor role signatures from nearest to farthest, e.g. "form" / "list". pub ancestors: Vec, /// Parent role. pub parent_tag: String, /// Parent accessible name / text. pub parent_text: String, /// Index among same-role siblings. pub sibling_index: u32, /// Count of same-role siblings. pub sibling_count: u32, } /// Component weights. They sum to 1.0 so the total score lands in 0..1. /// Tuned for AX-derived fingerprints: the accessible name dominates, with role /// and tree structure carrying disambiguation when the name has changed (which /// is exactly when the exact role+name+nth fallback failed and we got here). const W_TAG: f64 = 0.20; const W_TEXT: f64 = 0.40; const W_ATTRS: f64 = 0.10; const W_ANCESTORS: f64 = 0.20; const W_PARENT_SIBLING: f64 = 0.10; /// Per-attribute importance for the attribute-overlap score. Strong identity /// signals (a link's url) outweigh weak ones (heading level). fn attr_weight(name: &str) -> f64 { match name { "url" | "value" => 3.0, "checked" => 2.0, _ => 1.0, } } /// Levenshtein-based string similarity in 0..1 (1.0 = identical). Two empty /// strings are treated as a perfect match (consistent absence of text). pub fn string_similarity(a: &str, b: &str) -> f64 { if a == b { return 1.0; } let a: Vec = a.chars().collect(); let b: Vec = b.chars().collect(); let max_len = a.len().max(b.len()); if max_len == 0 { return 1.0; } let dist = levenshtein(&a, &b); 1.0 - (dist as f64 / max_len as f64) } fn levenshtein(a: &[char], b: &[char]) -> usize { if a.is_empty() { return b.len(); } if b.is_empty() { return a.len(); } let mut prev: Vec = (0..=b.len()).collect(); let mut cur = vec![0usize; b.len() + 1]; for (i, &ca) in a.iter().enumerate() { cur[0] = i + 1; for (j, &cb) in b.iter().enumerate() { let cost = if ca == cb { 0 } else { 1 }; cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost); } std::mem::swap(&mut prev, &mut cur); } prev[b.len()] } /// Jaccard similarity over whitespace-separated tokens (used for `class`). fn token_jaccard(a: &str, b: &str) -> f64 { let sa: std::collections::BTreeSet<&str> = a.split_whitespace().collect(); let sb: std::collections::BTreeSet<&str> = b.split_whitespace().collect(); if sa.is_empty() && sb.is_empty() { return 1.0; } let inter = sa.intersection(&sb).count() as f64; let union = sa.union(&sb).count() as f64; if union == 0.0 { 1.0 } else { inter / union } } /// Length-ratio of the longest common subsequence over two ancestor sequences. fn lcs_ratio(a: &[String], b: &[String]) -> f64 { if a.is_empty() && b.is_empty() { return 1.0; } if a.is_empty() || b.is_empty() { return 0.0; } let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1]; for i in 0..a.len() { for j in 0..b.len() { dp[i + 1][j + 1] = if a[i] == b[j] { dp[i][j] + 1 } else { dp[i][j + 1].max(dp[i + 1][j]) }; } } let lcs = dp[a.len()][b.len()] as f64; (2.0 * lcs) / (a.len() + b.len()) as f64 } fn attr_score(base: &BTreeMap, cand: &BTreeMap) -> f64 { let mut names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); names.extend(base.keys().map(|s| s.as_str())); names.extend(cand.keys().map(|s| s.as_str())); if names.is_empty() { return 1.0; // no attributes on either side — neutral } let mut total = 0.0; let mut got = 0.0; for name in names { let w = attr_weight(name); total += w; // present on only one side → no credit if let (Some(a), Some(b)) = (base.get(name), cand.get(name)) { if name == "class" { got += w * token_jaccard(a, b); } else if a == b { got += w; } } } if total == 0.0 { 1.0 } else { got / total } } fn parent_sibling_score(base: &ElementFingerprint, cand: &ElementFingerprint) -> f64 { // Split the 0.10 budget: parent tag 0.4, parent text 0.3, sibling pos 0.3. let parent_tag = if base.parent_tag == cand.parent_tag { 1.0 } else { 0.0 }; let parent_text = string_similarity(&base.parent_text, &cand.parent_text); let span = base.sibling_count.max(1) as f64; let delta = (base.sibling_index as i64 - cand.sibling_index as i64).unsigned_abs() as f64; let sibling = 1.0 - (delta / span).min(1.0); 0.4 * parent_tag + 0.3 * parent_text + 0.3 * sibling } /// Similarity score in 0..1 between a stored baseline and a candidate element. pub fn score(base: &ElementFingerprint, cand: &ElementFingerprint) -> f64 { let tag = if base.tag == cand.tag { 1.0 } else { 0.0 }; let text = string_similarity(&base.text, &cand.text); let attrs = attr_score(&base.attrs, &cand.attrs); let ancestors = lcs_ratio(&base.ancestors, &cand.ancestors); let parent_sibling = parent_sibling_score(base, cand); W_TAG * tag + W_TEXT * text + W_ATTRS * attrs + W_ANCESTORS * ancestors + W_PARENT_SIBLING * parent_sibling } /// Why a relocation was rejected. #[derive(Debug, Clone, PartialEq)] pub enum RejectReason { /// No candidates to score. NoCandidates, /// Best score below [`ADAPTIVE_THRESHOLD`]. LowScore { best: f64 }, /// Best score too close to the runner-up (below [`ADAPTIVE_MARGIN`]). Ambiguous { best: f64, second: f64 }, } /// A successful relocation decision. #[derive(Debug, Clone, PartialEq)] pub struct Relocation { /// Chosen candidate's backend node id. pub backend_node_id: i64, /// Winning score. pub score: f64, /// Runner-up score (0.0 when there was only one candidate). pub second_score: f64, } /// Pick the best candidate, accepting only when confident. `candidates` is a /// list of `(backend_node_id, fingerprint)` for the current page. pub fn pick_best( base: &ElementFingerprint, candidates: &[(i64, ElementFingerprint)], threshold: f64, margin: f64, ) -> Result { if candidates.is_empty() { return Err(RejectReason::NoCandidates); } let mut scored: Vec<(i64, f64)> = candidates .iter() .map(|(id, fp)| (*id, score(base, fp))) .collect(); // Highest score first; stable enough for deterministic ties. scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); let (best_id, best) = scored[0]; let second = scored.get(1).map(|(_, s)| *s).unwrap_or(0.0); if best < threshold { return Err(RejectReason::LowScore { best }); } if best - second < margin { return Err(RejectReason::Ambiguous { best, second }); } Ok(Relocation { backend_node_id: best_id, score: best, second_score: second, }) } #[cfg(test)] mod tests { use super::*; fn fp(tag: &str, text: &str, attrs: &[(&str, &str)]) -> ElementFingerprint { ElementFingerprint { tag: tag.to_string(), text: text.to_string(), attrs: attrs .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), ..Default::default() } } #[test] fn identical_fingerprints_score_one() { let a = fp( "button", "Submit", &[("id", "go"), ("class", "btn primary")], ); assert!((score(&a, &a) - 1.0).abs() < 1e-9); } #[test] fn different_tag_caps_score_below_threshold() { let a = fp("button", "Submit", &[("id", "go")]); let b = fp("a", "Submit", &[("id", "go")]); // Same text + same attrs but different role: must lose the role weight // (W_TAG = 0.20), landing around 0.80 and below a perfect match. let s = score(&a, &b); assert!(s < 0.85 && s > 0.75, "got {s}"); } #[test] fn string_similarity_basics() { assert_eq!(string_similarity("abc", "abc"), 1.0); assert_eq!(string_similarity("", ""), 1.0); assert!(string_similarity("Submit", "Submit now") > 0.5); assert!(string_similarity("Add post", "Post all") < 0.6); } #[test] fn class_uses_token_overlap() { let a = fp("div", "", &[("class", "card primary big")]); let b = fp("div", "", &[("class", "card primary")]); // partial class overlap should still score high (tag+text match, attrs partial) let s = score(&a, &b); assert!(s > 0.85, "got {s}"); } #[test] fn ancestors_lcs() { let mut a = fp("button", "OK", &[]); let mut b = fp("button", "OK", &[]); a.ancestors = vec!["form#f".into(), "div.col".into(), "body".into()]; // b wrapped in an extra div — DOM path changed but mostly preserved b.ancestors = vec![ "form#f".into(), "div.wrap".into(), "div.col".into(), "body".into(), ]; let s = score(&a, &b); assert!(s > 0.85, "got {s}"); } #[test] fn pick_best_accepts_clear_winner() { let base = fp("button", "Submit", &[("id", "go")]); let winner = fp("button", "Submit", &[("id", "go")]); let other = fp("a", "Home", &[("href", "/")]); let out = pick_best( &base, &[(10, other), (20, winner)], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN, ) .expect("should accept"); assert_eq!(out.backend_node_id, 20); assert!(out.score > out.second_score); } #[test] fn pick_best_rejects_ambiguous_twins() { let base = fp("button", "Delete", &[("class", "btn danger")]); // Two near-identical delete buttons — must refuse to guess. let twin_a = fp("button", "Delete", &[("class", "btn danger")]); let twin_b = fp("button", "Delete", &[("class", "btn danger")]); let err = pick_best( &base, &[(1, twin_a), (2, twin_b)], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN, ) .unwrap_err(); assert!(matches!(err, RejectReason::Ambiguous { .. }), "got {err:?}"); } #[test] fn pick_best_rejects_low_score() { let base = fp("button", "Submit order", &[("id", "checkout")]); let junk = fp("span", "unrelated footer text", &[("class", "muted")]); let err = pick_best(&base, &[(1, junk)], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN).unwrap_err(); assert!(matches!(err, RejectReason::LowScore { .. }), "got {err:?}"); } #[test] fn pick_best_no_candidates() { let base = fp("button", "x", &[]); assert_eq!( pick_best(&base, &[], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN).unwrap_err(), RejectReason::NoCandidates ); } }