1use amberfork_model::{Payload, Step};
15use serde_json::Value;
16
17const TEXT_CAP: usize = 600;
20
21pub trait CostModel {
26 fn cost(&self, a: &Step, b: &Step) -> f64;
28}
29
30#[derive(Debug, Default, Clone, Copy)]
42pub struct LexicalCost;
43
44impl CostModel for LexicalCost {
45 fn cost(&self, a: &Step, b: &Step) -> f64 {
46 let text_a = step_text(a);
47 let text_b = step_text(b);
48 1.0 - gestalt_ratio(&tokens(&text_a), &tokens(&text_b))
49 }
50}
51
52fn step_text(step: &Step) -> String {
58 let out = match &step.outputs {
59 None => String::new(),
60 Some(Payload::Text(s)) => s.clone(),
61 Some(Payload::Object(map)) => {
62 let mut buf = String::new();
63 sorted_json(&mut buf, &Value::Object(map.clone()));
64 buf
65 }
66 Some(Payload::Other(value)) => {
67 let mut buf = String::new();
68 sorted_json(&mut buf, value);
69 buf
70 }
71 };
72 format!("{}: {}", step.name, out)
73 .chars()
74 .take(TEXT_CAP)
75 .collect()
76}
77
78fn sorted_json(out: &mut String, value: &Value) {
82 match value {
83 Value::Object(map) => {
84 let mut keys: Vec<&String> = map.keys().collect();
85 keys.sort();
86 out.push('{');
87 for (i, key) in keys.iter().enumerate() {
88 if i > 0 {
89 out.push(',');
90 }
91 out.push_str(&Value::String((*key).clone()).to_string());
92 out.push(':');
93 sorted_json(out, &map[key.as_str()]);
94 }
95 out.push('}');
96 }
97 Value::Array(items) => {
98 out.push('[');
99 for (i, item) in items.iter().enumerate() {
100 if i > 0 {
101 out.push(',');
102 }
103 sorted_json(out, item);
104 }
105 out.push(']');
106 }
107 leaf => out.push_str(&leaf.to_string()),
108 }
109}
110
111fn tokens(text: &str) -> Vec<String> {
113 let mut toks = Vec::new();
114 let mut current = String::new();
115 for ch in text.chars() {
116 let ch = ch.to_ascii_lowercase();
117 if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
118 current.push(ch);
119 } else if !current.is_empty() {
120 toks.push(std::mem::take(&mut current));
121 }
122 }
123 if !current.is_empty() {
124 toks.push(current);
125 }
126 toks
127}
128
129fn gestalt_ratio(a: &[String], b: &[String]) -> f64 {
133 let total = a.len() + b.len();
134 if total == 0 {
135 return 1.0;
136 }
137 let mut matches = 0;
138 let mut ranges = vec![(0, a.len(), 0, b.len())];
140 while let Some((a_lo, a_hi, b_lo, b_hi)) = ranges.pop() {
141 let (i, j, size) = longest_match(a, b, a_lo, a_hi, b_lo, b_hi);
142 if size > 0 {
143 matches += size;
144 ranges.push((a_lo, i, b_lo, j));
145 ranges.push((i + size, a_hi, j + size, b_hi));
146 }
147 }
148 2.0 * matches as f64 / total as f64
149}
150
151fn longest_match(
155 a: &[String],
156 b: &[String],
157 a_lo: usize,
158 a_hi: usize,
159 b_lo: usize,
160 b_hi: usize,
161) -> (usize, usize, usize) {
162 let mut best = (a_lo, b_lo, 0);
163 let mut run_ending_at = vec![0usize; b_hi.saturating_sub(b_lo)];
164 for (i, a_tok) in a.iter().enumerate().take(a_hi).skip(a_lo) {
165 let mut next_run = vec![0usize; run_ending_at.len()];
166 for (offset, run) in next_run.iter_mut().enumerate() {
167 let j = b_lo + offset;
168 if *a_tok != b[j] {
169 continue;
170 }
171 let len = if offset > 0 {
172 run_ending_at[offset - 1]
173 } else {
174 0
175 } + 1;
176 *run = len;
177 if len > best.2 {
178 best = (i + 1 - len, j + 1 - len, len);
179 }
180 }
181 run_ending_at = next_run;
182 }
183 best
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use amberfork_model::StepKind;
190 use serde_json::{Map, Value};
191
192 fn step(name: &str, outputs: Option<Payload>) -> Step {
194 Step {
195 idx: 0,
196 kind: StepKind::Tool,
197 name: name.to_string(),
198 inputs: None,
199 outputs,
200 attrs: Map::new(),
201 t_start: None,
202 t_end: None,
203 parent_idx: None,
204 }
205 }
206
207 fn text_step(name: &str, outputs: &str) -> Step {
208 step(name, Some(Payload::Text(outputs.to_string())))
209 }
210
211 #[test]
212 fn identical_steps_cost_zero() {
213 let a = text_step("web.search", "q='census 2020' -> 9 results");
214 assert_eq!(LexicalCost.cost(&a, &a.clone()), 0.0);
215 }
216
217 #[test]
218 fn disjoint_steps_cost_one() {
219 let a = text_step("alpha", "one two three");
220 let b = text_step("beta", "four five six");
221 assert_eq!(LexicalCost.cost(&a, &b), 1.0);
222 }
223
224 #[test]
225 fn cost_is_symmetric() {
226 let a = text_step("planner", "search for census data, then verify the figure");
227 let b = text_step(
228 "planner",
229 "verify the census figure against an official source",
230 );
231 assert_eq!(LexicalCost.cost(&a, &b), LexicalCost.cost(&b, &a));
232 }
233
234 #[test]
235 fn known_ratio_by_hand() {
236 let a = text_step("step", "alpha beta gamma");
240 let b = text_step("step", "alpha gamma");
241 let expected = 1.0 - 6.0 / 7.0;
242 assert!((LexicalCost.cost(&a, &b) - expected).abs() < 1e-12);
243 }
244
245 #[test]
246 fn tokenization_folds_case_and_punctuation() {
247 let a = text_step("web.fetch", "URL=census.gov/data; Status: OK!");
249 let b = text_step("web.fetch", "url census gov data status ok");
250 assert_eq!(LexicalCost.cost(&a, &b), 0.0);
251 }
252
253 #[test]
254 fn object_payloads_compare_deterministically() {
255 let mut inner1 = Map::new();
259 inner1.insert("page".into(), Value::from(1));
260 inner1.insert("lang".into(), Value::from("en"));
261 let mut inner2 = Map::new();
262 inner2.insert("lang".into(), Value::from("en"));
263 inner2.insert("page".into(), Value::from(1));
264 let mut m1 = Map::new();
265 m1.insert("status".into(), Value::from("ok"));
266 m1.insert("count".into(), Value::from(9));
267 m1.insert("meta".into(), Value::Object(inner1));
268 let mut m2 = Map::new();
269 m2.insert("meta".into(), Value::Object(inner2));
270 m2.insert("count".into(), Value::from(9));
271 m2.insert("status".into(), Value::from("ok"));
272 let a = step("web.search", Some(Payload::Object(m1)));
273 let b = step("web.search", Some(Payload::Object(m2)));
274 assert_eq!(LexicalCost.cost(&a, &b), 0.0);
275 }
276
277 #[test]
278 fn text_beyond_cap_is_ignored() {
279 let shared = "token ".repeat(120); let a = text_step("dump", &format!("{shared}completely different tail one"));
282 let b = text_step("dump", &format!("{shared}nothing alike in this suffix"));
283 assert_eq!(LexicalCost.cost(&a, &b), 0.0);
284 }
285
286 #[test]
287 fn contentless_steps_compare_by_name() {
288 let a = step("fetch", None);
289 assert_eq!(LexicalCost.cost(&a, &a.clone()), 0.0);
290 let b = step("q7", None);
291 assert_eq!(LexicalCost.cost(&a, &b), 1.0);
292 }
293
294 #[test]
295 fn non_ascii_text_is_safe() {
296 let a = text_step("reader", "café population ≈ 8,443,000 ☕");
297 let b = text_step("reader", "cafe population 8 443 000");
298 let cost = LexicalCost.cost(&a, &b);
299 assert!((0.0..=1.0).contains(&cost));
300 assert!(cost < 0.5);
303 }
304}