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.
25///
26/// The trait is split at the per-step precomputation seam: [`prepare`](CostModel::prepare)
27/// digests one step into whatever form the model compares — [`LexicalCost`]'s token sequence,
28/// a future tf-idf model's term vector, an embedding — and
29/// [`cost_prepared`](CostModel::cost_prepared) scores two digests. The aligner prepares each
30/// run once (O(n+m)) and only scores inside the O(n·m) cost matrix, so per-step work never
31/// multiplies by the number of cells (issue #16).
32pub trait CostModel {
33    /// The per-step precomputation this model compares by.
34    type Prepared;
35
36    /// Digest one step into its comparable form.
37    fn prepare(&self, step: &Step) -> Self::Prepared;
38
39    /// Cost of aligning two prepared steps.
40    fn cost_prepared(&self, a: &Self::Prepared, b: &Self::Prepared) -> f64;
41
42    /// Cost of aligning `a` with `b`: prepare both, score once. A one-off convenience — code
43    /// that compares many pairs prepares each step once and calls
44    /// [`cost_prepared`](CostModel::cost_prepared).
45    fn cost(&self, a: &Step, b: &Step) -> f64 {
46        self.cost_prepared(&self.prepare(a), &self.prepare(b))
47    }
48}
49
50/// The v1 default cost model: `1 - gestalt_ratio` over the steps' token sequences.
51///
52/// A step's comparable text is `"{name}: {outputs}"` capped at [`TEXT_CAP`] characters —
53/// outputs only, not inputs, because outputs carry the step's observable behavior while inputs
54/// largely echo the previous step's outputs (spike 001 design, kept). The text is tokenized to
55/// lowercase ASCII-alphanumeric runs, the same vocabulary a future tf-idf model would use.
56///
57/// Its prepared form is the token sequence itself, so a full alignment tokenizes each step
58/// once (O(n+m)) and the O(n·m) cells spend only the gestalt ratio (issue #16; before/after
59/// curves in notebook 022/023).
60#[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
75/// The text a step is compared by: `"{name}: {outputs}"`, capped at [`TEXT_CAP`] characters.
76/// Structured payloads serialize through [`sorted_json`], so the text — and every cost derived
77/// from it — is deterministic regardless of a payload's key order. That property is
78/// established HERE, deliberately: the workspace's `serde_json` preserves insertion order (the
79/// issue-#17 byte-parity requirement), so an engine invariant must not lean on the map type.
80fn 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
101/// Compact JSON with keys sorted at every nesting level — the canonical comparable form of a
102/// structured payload. Non-object leaves render through `serde_json` (`Value`'s `Display`),
103/// so string escaping and number formatting stay standard.
104fn 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
134/// Lowercase ASCII-alphanumeric runs of `text`; everything else is a separator.
135fn 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
152/// Gestalt (Ratcliff–Obershelp) similarity of two token sequences: `2·M / (|a| + |b|)`, where
153/// `M` totals the recursively-found longest matching blocks. Two empty sequences are identical
154/// (`1.0`).
155fn 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    // Explicit work list instead of recursion; block order doesn't affect the sum.
162    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
174/// Longest block of tokens common to `a[a_lo..a_hi]` and `b[b_lo..b_hi]`, as
175/// `(start_in_a, start_in_b, len)`; ties resolve to the earliest position in `a`, then `b`.
176/// One row of a longest-common-substring DP at a time, keyed by end position in `b`.
177fn 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    /// Idx is pinned to 0 everywhere here: the cost model reads only `name` and content.
216    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        // Tokens: [step, alpha, beta, gamma] vs [step, alpha, gamma].
246        // Longest block [step, alpha] (2) + remainder [gamma] (1) -> M = 3.
247        // ratio = 2*3 / (4+3) = 6/7, cost = 1/7.
248        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        // Same tokens once lowercased and split on non-alphanumerics.
257        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        // Same object regardless of insertion order — at every nesting level. The workspace's
265        // serde_json preserves insertion order (issue #17), so this invariant is the cost
266        // model's own sorted serialization, not the map type's.
267        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        // Identical up to the cap, wildly different after: the cap must make them equal.
289        let shared = "token ".repeat(120); // 720 chars > TEXT_CAP
290        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        // "café" tokenizes to "caf" (ASCII vocabulary), so it cannot fully match "cafe" —
310        // but the numbers and "population" do.
311        assert!(cost < 0.5);
312    }
313}