Skip to main content

amberfork_align/
cost.rs

1//! The cost-model seam: how unlike two steps are, as a number the aligner can minimize.
2//!
3//! [`CostModel`] is the trait every similarity signal implements; [`LexicalCost`] is the v1
4//! default. Per the 2026-07-08 Amendment B (and notebook 003), the default is deliberately
5//! lexical — deterministic, dependency-free, seed-stable — and any richer signal (tf-idf,
6//! embeddings) competes through this same trait and must beat it on dev fixtures to replace it.
7//!
8//! `LexicalCost` is a token-level gestalt (Ratcliff–Obershelp) ratio, not a port of the spike's
9//! character-level `difflib` call: notebook 003 measured the token variant equal-or-better on
10//! every dev-fixture condition (0.75 vs 0.70 exact on the committed noise pairs) while dropping
11//! `difflib`'s autojunk quirk and ~36× of the DP work. The fixtures are the contract, not
12//! bit-parity with Python.
13
14use amberfork_model::{Payload, Step};
15use serde_json::Value;
16
17/// Cap on the step text used for similarity, in characters. Long tool dumps otherwise dominate
18/// both the signal and the runtime (spike constant, kept as-is).
19const TEXT_CAP: usize = 600;
20
21/// A step-vs-step alignment cost in `[0, 1]`: `0.0` = identical content, `1.0` = nothing in
22/// common. Implementations must be deterministic and symmetric — the aligner's output is part
23/// of the reproducibility promise, and the fork rule's `tau` thresholds compare against these
24/// values directly.
25pub trait CostModel {
26    /// Cost of aligning `a` with `b`.
27    fn cost(&self, a: &Step, b: &Step) -> f64;
28}
29
30/// The v1 default cost model: `1 - gestalt_ratio` over the steps' token sequences.
31///
32/// A step's comparable text is `"{name}: {outputs}"` capped at [`TEXT_CAP`] characters —
33/// outputs only, not inputs, because outputs carry the step's observable behavior while inputs
34/// largely echo the previous step's outputs (spike 001 design, kept). The text is tokenized to
35/// lowercase ASCII-alphanumeric runs, the same vocabulary a future tf-idf model would use.
36///
37/// Perf note: tokens are recomputed on every call, so a full alignment does O(n·m)
38/// tokenizations where O(n+m) would do. Deliberate at current scale (the gestalt DP dominates;
39/// the whole dev set diffs in ~2s unoptimized) — this is the first place to cache if very long
40/// runs ever feel slow.
41#[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
52/// The text a step is compared by: `"{name}: {outputs}"`, capped at [`TEXT_CAP`] characters.
53/// Structured payloads serialize through [`sorted_json`], so the text — and every cost derived
54/// from it — is deterministic regardless of a payload's key order. That property is
55/// established HERE, deliberately: the workspace's `serde_json` preserves insertion order (the
56/// issue-#17 byte-parity requirement), so an engine invariant must not lean on the map type.
57fn 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
78/// Compact JSON with keys sorted at every nesting level — the canonical comparable form of a
79/// structured payload. Non-object leaves render through `serde_json` (`Value`'s `Display`),
80/// so string escaping and number formatting stay standard.
81fn 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
111/// Lowercase ASCII-alphanumeric runs of `text`; everything else is a separator.
112fn 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
129/// Gestalt (Ratcliff–Obershelp) similarity of two token sequences: `2·M / (|a| + |b|)`, where
130/// `M` totals the recursively-found longest matching blocks. Two empty sequences are identical
131/// (`1.0`).
132fn 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    // Explicit work list instead of recursion; block order doesn't affect the sum.
139    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
151/// Longest block of tokens common to `a[a_lo..a_hi]` and `b[b_lo..b_hi]`, as
152/// `(start_in_a, start_in_b, len)`; ties resolve to the earliest position in `a`, then `b`.
153/// One row of a longest-common-substring DP at a time, keyed by end position in `b`.
154fn 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    /// Minimal step with the two fields the cost model reads.
193    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        // Tokens: [step, alpha, beta, gamma] vs [step, alpha, gamma].
237        // Longest block [step, alpha] (2) + remainder [gamma] (1) -> M = 3.
238        // ratio = 2*3 / (4+3) = 6/7, cost = 1/7.
239        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        // Same tokens once lowercased and split on non-alphanumerics.
248        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        // Same object regardless of insertion order — at every nesting level. The workspace's
256        // serde_json preserves insertion order (issue #17), so this invariant is the cost
257        // model's own sorted serialization, not the map type's.
258        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        // Identical up to the cap, wildly different after: the cap must make them equal.
280        let shared = "token ".repeat(120); // 720 chars > TEXT_CAP
281        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        // "café" tokenizes to "caf" (ASCII vocabulary), so it cannot fully match "cafe" —
301        // but the numbers and "population" do.
302        assert!(cost < 0.5);
303    }
304}