Skip to main content

amberfork_align/
fork.rs

1//! The resync-k fork rule: where unrecovered divergence begins.
2//!
3//! Locked by Amendment 2026-07-08.A after the naive rule ("fork = first non-sync move")
4//! measured 0.00 exact localization — benign retries and rewordings are almost always the
5//! first non-sync move. The validated rule: walk the alignment's non-sync *blocks*; a block
6//! immediately followed by at least `resync_k` in-sync moves is a benign blip the alignment
7//! recovered from; **the fork is the first block it does not recover from**. A fully in-sync
8//! alignment has no fork — [`None`] is the designed converged state, not an error.
9
10use amberfork_model::{Fork, Move, MoveKind};
11
12/// Parameters of the fork rule. Defaults are dev-calibrated (notebook 001–003): `resync_k = 2`
13/// per Amendment A; `tau = 0.3`, the middle of the plateau (0.2–0.4 scored identically for the
14/// token-level lexical cost model on the dev pairs).
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct ForkParams {
17    /// Sync threshold on move cost: a sync move with `cost <= tau` counts as in-sync.
18    pub tau: f64,
19    /// How many consecutive in-sync moves after a non-sync block mean "recovered".
20    pub resync_k: usize,
21}
22
23impl Default for ForkParams {
24    fn default() -> Self {
25        Self {
26            tau: 0.3,
27            resync_k: 2,
28        }
29    }
30}
31
32/// Locate the fork in an alignment, or `None` if the runs converged (all blocks benign).
33///
34/// The returned [`Fork`] points at the first move of the unrecovered block: `index` into the
35/// alignment, the move's own step indices, and a confidence in `[0, 1]` measuring how far the
36/// move's divergence evidence clears `tau` — `(evidence - tau) / (1 - tau)`, where evidence is
37/// a sync move's cost or a gap move's distance-to-closest-counterpart ([`crate::align`]'s gap
38/// confidence). A marginal call scores near 0, a total mismatch scores 1.
39pub fn find_fork(alignment: &[Move], params: &ForkParams) -> Option<Fork> {
40    let in_sync = |m: &Move| m.kind == MoveKind::Sync && m.cost <= params.tau;
41
42    let mut idx = 0;
43    while idx < alignment.len() {
44        if in_sync(&alignment[idx]) {
45            idx += 1;
46            continue;
47        }
48        // A non-sync block starts here; find where it ends...
49        let block_start = idx;
50        while idx < alignment.len() && !in_sync(&alignment[idx]) {
51            idx += 1;
52        }
53        // ...and how many in-sync moves immediately follow it.
54        let resynced = alignment[idx..].iter().take_while(|m| in_sync(m)).count();
55        if resynced >= params.resync_k {
56            continue; // benign blip — the alignment recovered
57        }
58        let first = &alignment[block_start];
59        return Some(Fork {
60            index: block_start,
61            a_step: first.a_idx,
62            b_step: first.b_idx,
63            confidence: divergence_confidence(first, params.tau),
64        });
65    }
66    None
67}
68
69/// How far a move's divergence evidence clears `tau`, rescaled to `[0, 1]`. Evidence is a sync
70/// move's cost, or a gap move's distance-to-closest-counterpart (its `confidence`, as stamped
71/// by [`crate::align`]). A gapped step with a sync-grade twin on the other side clamps to 0 —
72/// an honest "this fork is a weak call".
73fn divergence_confidence(m: &Move, tau: f64) -> f64 {
74    let evidence = match m.kind {
75        MoveKind::Sync => m.cost,
76        MoveKind::Log | MoveKind::Model => m.confidence,
77    };
78    ((evidence - tau) / (1.0 - tau).max(f64::EPSILON)).clamp(0.0, 1.0)
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    const OPEN: f64 = 0.6;
86
87    fn sync(i: usize, cost: f64) -> Move {
88        Move::sync(i, i, cost, 1.0 - cost)
89    }
90
91    /// A log move whose distance-to-closest-counterpart is `conf`.
92    fn log(i: usize, conf: f64) -> Move {
93        Move::log(i, OPEN, conf)
94    }
95
96    fn kinds_at(fork: &Fork, moves: &[Move]) -> MoveKind {
97        moves[fork.index].kind
98    }
99
100    #[test]
101    fn all_sync_alignment_converges() {
102        let moves = vec![sync(0, 0.0), sync(1, 0.1), sync(2, 0.2)];
103        assert_eq!(find_fork(&moves, &ForkParams::default()), None);
104    }
105
106    #[test]
107    fn empty_alignment_converges() {
108        assert_eq!(find_fork(&[], &ForkParams::default()), None);
109    }
110
111    #[test]
112    fn cost_exactly_tau_is_in_sync() {
113        let params = ForkParams::default();
114        let moves = vec![sync(0, params.tau)];
115        assert_eq!(find_fork(&moves, &params), None);
116    }
117
118    #[test]
119    fn benign_blip_is_recovered_from() {
120        // One gap move, then >= resync_k in-sync moves: a retry, not a fork.
121        let moves = vec![sync(0, 0.0), log(1, 1.0), sync(1, 0.1), sync(2, 0.0)];
122        assert_eq!(find_fork(&moves, &ForkParams::default()), None);
123    }
124
125    #[test]
126    fn insufficient_resync_is_a_fork() {
127        // Only one in-sync move follows the block; k = 2 says that is not recovery.
128        let moves = vec![sync(0, 0.0), log(1, 1.0), sync(1, 0.1)];
129        let fork = find_fork(&moves, &ForkParams::default()).expect("fork");
130        assert_eq!(fork.index, 1);
131        assert_eq!(fork.b_step, Some(1));
132        assert_eq!(fork.a_step, None);
133    }
134
135    #[test]
136    fn costly_sync_is_divergence() {
137        // A sync move above tau is a content divergence even though steps paired up.
138        let moves = vec![sync(0, 0.0), sync(1, 0.9), sync(2, 0.95)];
139        let fork = find_fork(&moves, &ForkParams::default()).expect("fork");
140        assert_eq!(fork.index, 1);
141        assert_eq!((fork.a_step, fork.b_step), (Some(1), Some(1)));
142    }
143
144    #[test]
145    fn divergence_running_to_the_end_is_a_fork() {
146        let moves = vec![sync(0, 0.0), sync(1, 0.0), log(2, 0.8), log(3, 0.9)];
147        let fork = find_fork(&moves, &ForkParams::default()).expect("fork");
148        assert_eq!(fork.index, 2);
149    }
150
151    #[test]
152    fn fork_is_first_unrecovered_block_not_first_blip() {
153        // blip (recovers, k=2 syncs) ... then the real fork.
154        let moves = vec![
155            sync(0, 0.0),
156            log(1, 1.0),  // blip
157            sync(1, 0.0), // recovery 1
158            sync(2, 0.1), // recovery 2
159            sync(3, 0.9), // the real fork: costly sync block ...
160            log(4, 1.0),  // ... never recovers
161        ];
162        let fork = find_fork(&moves, &ForkParams::default()).expect("fork");
163        assert_eq!(fork.index, 4, "must skip the recovered blip");
164        assert!(kinds_at(&fork, &moves) == MoveKind::Sync);
165    }
166
167    #[test]
168    fn confidence_scales_with_evidence_over_tau() {
169        let params = ForkParams::default();
170        // Marginal: cost barely above tau -> confidence near 0.
171        let marginal = find_fork(&[sync(0, 0.31)], &params).expect("fork");
172        assert!(marginal.confidence < 0.05, "marginal call must read low");
173        // Total mismatch: cost 1.0 -> confidence 1.0.
174        let certain = find_fork(&[sync(0, 1.0)], &params).expect("fork");
175        assert_eq!(certain.confidence, 1.0);
176        // Gap move: evidence is its distance-to-closest-counterpart, here 0.65.
177        let gappy = find_fork(&[log(0, 0.65)], &params).expect("fork");
178        let expected = (0.65 - params.tau) / (1.0 - params.tau);
179        assert!((gappy.confidence - expected).abs() < 1e-12);
180        // A gap whose step has a sync-grade twin elsewhere: honest zero, not negative.
181        let twinned = find_fork(&[log(0, 0.1)], &params).expect("fork");
182        assert_eq!(twinned.confidence, 0.0);
183    }
184}