patchloom/api/fuzzy_span.rs
1//! Over-wide fuzzy span policy for library hosts (#1981).
2
3use super::AGENT_MIN_FUZZY_SCORE;
4
5/// Thresholds for [`fuzzy_span_suspicious`] / [`fuzzy_span_suspicious_with_policy`] (#1981).
6///
7/// Defaults match the Bline-informed starting policy (host refuse, not library
8/// auto-revert): Unicode char counts, wide-span cap, and a tighter ratio near
9/// the agent fuzzy floor.
10#[derive(Debug, Clone, Copy, PartialEq)]
11#[non_exhaustive]
12pub struct FuzzySpanPolicy {
13 /// Refuse when `matched_chars > max_ratio * old_chars` (ceiling).
14 pub max_ratio: f64,
15 /// Also allow at least `old_chars + abs_extra_chars` before the ratio alone
16 /// can fire (so tiny `old` strings do not explode on modest expansions).
17 pub abs_extra_chars: usize,
18 /// Lower bound of the "near floor" score band (inclusive), typically
19 /// [`AGENT_MIN_FUZZY_SCORE`].
20 pub near_floor_score_lo: f64,
21 /// Upper bound of the near-floor score band (exclusive).
22 pub near_floor_score_hi: f64,
23 /// Within the near-floor score band, refuse when matched/old char ratio
24 /// exceeds this value.
25 pub near_floor_ratio: f64,
26}
27
28impl Default for FuzzySpanPolicy {
29 fn default() -> Self {
30 Self {
31 max_ratio: 4.0,
32 abs_extra_chars: 40,
33 near_floor_score_lo: AGENT_MIN_FUZZY_SCORE,
34 near_floor_score_hi: 0.95,
35 near_floor_ratio: 2.0,
36 }
37 }
38}
39
40/// Whether a **fuzzy** matched span is suspiciously wider than the requested
41/// `old` string (#1981).
42///
43/// Hosts should call this **before treating Apply as trusted** when
44/// `match_mode` is fuzzy (or when `matched_text` is present from fuzzy
45/// recovery). Uses Unicode scalar counts (`chars().count()`), not bytes.
46///
47/// Default thresholds ([`FuzzySpanPolicy::default`]):
48/// - `matched_chars > max(4 * old_chars, old_chars + 40)`, or
49/// - score in `[0.90, 0.95)` and `matched_chars / old_chars > 2`
50///
51/// Returns `false` when `matched_text` is `None` or empty (nothing to judge).
52/// Empty `old` with a non-empty match is treated as suspicious.
53///
54/// Prefer this helper over a redundant length field on [`super::EditResult`]: when
55/// `matched_text` is populated, hosts already have the span.
56///
57/// ```rust
58/// use patchloom::api::{fuzzy_span_suspicious, AGENT_MIN_FUZZY_SCORE};
59///
60/// // Token-scale match: not suspicious.
61/// assert!(!fuzzy_span_suspicious(
62/// "process_data",
63/// Some("process_data"),
64/// Some(0.99),
65/// ));
66/// // Near-floor score (0.90) + more than 2x expansion: suspicious.
67/// // (Wide-cap path needs matched_chars > max(4*old, old+40).)
68/// let matched = "process_data_and_much_more_tail"; // 30 chars vs 12 → ratio 2.5
69/// assert!(fuzzy_span_suspicious(
70/// "process_data",
71/// Some(matched),
72/// Some(AGENT_MIN_FUZZY_SCORE),
73/// ));
74/// ```
75#[must_use]
76pub fn fuzzy_span_suspicious(
77 old: &str,
78 matched_text: Option<&str>,
79 match_score: Option<f64>,
80) -> bool {
81 fuzzy_span_suspicious_with_policy(old, matched_text, match_score, &FuzzySpanPolicy::default())
82}
83
84/// Like [`fuzzy_span_suspicious`] with a custom [`FuzzySpanPolicy`].
85#[must_use]
86pub fn fuzzy_span_suspicious_with_policy(
87 old: &str,
88 matched_text: Option<&str>,
89 match_score: Option<f64>,
90 policy: &FuzzySpanPolicy,
91) -> bool {
92 let Some(matched) = matched_text.filter(|s| !s.is_empty()) else {
93 return false;
94 };
95 let old_n = old.chars().count();
96 let matched_n = matched.chars().count();
97 if old_n == 0 {
98 return matched_n > 0;
99 }
100 let ratio_cap = (old_n as f64 * policy.max_ratio).ceil() as usize;
101 let abs_cap = old_n.saturating_add(policy.abs_extra_chars);
102 let max_allowed = ratio_cap.max(abs_cap);
103 if matched_n > max_allowed {
104 return true;
105 }
106 if let Some(score) = match_score
107 && score >= policy.near_floor_score_lo
108 && score < policy.near_floor_score_hi
109 {
110 let ratio = matched_n as f64 / old_n as f64;
111 if ratio > policy.near_floor_ratio {
112 return true;
113 }
114 }
115 false
116}