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