Skip to main content

gam_sae/inference/
checkpoint_dynamics.rs

1//! Cross-checkpoint descriptive dynamics for SAE decoder curves.
2//!
3//! The input is a deterministic grid of already-fitted decoder values. Such a
4//! grid contains no observation-level scores, sampling covariance, or null
5//! distribution, so this module reports geometric displacements and chart
6//! transports only. It deliberately emits no standard errors, p-values, or
7//! e-values. Calibrated change evidence requires fit-time influence data (or an
8//! external replicated checkpoint experiment) in a future input schema.
9
10use crate::inference::layer_transport::{ChartTopology, LayerTransportReport, fit_layer_transport};
11use ndarray::{Array1, ArrayView1, ArrayView4};
12
13/// Inputs for one cross-checkpoint atom-dynamics run.
14///
15/// `decoder_grid` is `[n_checkpoints, n_atoms, n_grid, ambient_dim]`: the
16/// decoder curve of every atom sampled on the shared `latent_grid` at every
17/// checkpoint. `checkpoint_ids[c]` and `atom_names[a]` label the axes.
18pub struct CheckpointDynamicsInput<'a> {
19    pub decoder_grid: ArrayView4<'a, f64>,
20    pub checkpoint_ids: &'a [String],
21    pub atom_names: &'a [String],
22    pub latent_grid: ArrayView1<'a, f64>,
23}
24
25/// One deterministic decoder-grid change between consecutive checkpoints.
26pub struct CheckpointStepChange {
27    pub checkpoint_from: String,
28    pub checkpoint_to: String,
29    pub latent_coordinate: f64,
30    /// Ambient decoder displacement at the central latent-grid node.
31    pub displacement_at_mode: Array1<f64>,
32    pub l2_at_mode: f64,
33    /// Root mean squared ambient displacement over the complete shared grid.
34    pub grid_rms_l2: f64,
35    /// Largest ambient displacement over the complete shared grid.
36    pub grid_max_l2: f64,
37}
38
39/// The descriptive training trajectory of one atom across checkpoints.
40pub struct AtomTrajectory {
41    pub atom_name: String,
42    pub descriptive_step_changes: Vec<CheckpointStepChange>,
43    /// Consecutive-checkpoint chart correspondences (checkpoint axis reused as
44    /// the transport "layer" axis).
45    pub transports: Vec<LayerTransportReport>,
46}
47
48/// Run cross-checkpoint descriptive dynamics for every atom.
49///
50/// For each atom, walks consecutive checkpoints and, at each step `c → c+1`:
51/// 1. fits the transport map between the two checkpoints' latent charts
52///    ([`fit_layer_transport`], checkpoint axis as the layer axis);
53/// 2. reads direct decoder displacement summaries on the shared grid.
54pub fn checkpoint_atom_dynamics(
55    input: &CheckpointDynamicsInput<'_>,
56) -> Result<Vec<AtomTrajectory>, String> {
57    let shape = input.decoder_grid.shape();
58    let (n_checkpoints, n_atoms, n_grid, ambient_dim) = (shape[0], shape[1], shape[2], shape[3]);
59    if n_checkpoints < 2 {
60        return Err(format!(
61            "checkpoint dynamics needs at least two checkpoints, got {n_checkpoints}"
62        ));
63    }
64    if input.checkpoint_ids.len() != n_checkpoints {
65        return Err(format!(
66            "checkpoint_ids length {} disagrees with decoder grid checkpoint axis {n_checkpoints}",
67            input.checkpoint_ids.len()
68        ));
69    }
70    if input.atom_names.len() != n_atoms {
71        return Err(format!(
72            "atom_names length {} disagrees with decoder grid atom axis {n_atoms}",
73            input.atom_names.len()
74        ));
75    }
76    if input.latent_grid.len() != n_grid {
77        return Err(format!(
78            "latent_grid length {} disagrees with decoder grid latent axis {n_grid}",
79            input.latent_grid.len()
80        ));
81    }
82    if n_grid < 2 || ambient_dim == 0 {
83        return Err(format!(
84            "checkpoint dynamics needs a non-trivial grid ({n_grid}) and ambient dim ({ambient_dim})"
85        ));
86    }
87    if input.decoder_grid.iter().any(|v| !v.is_finite()) {
88        return Err("checkpoint dynamics decoder grid must be finite".to_string());
89    }
90    if input.latent_grid.iter().any(|v| !v.is_finite()) {
91        return Err("checkpoint dynamics latent grid must be finite".to_string());
92    }
93
94    // The mode index: the latent-grid node where the contrast is evaluated.
95    // Use the central node so it sits inside any chart and away from edge
96    // interpolation artifacts.
97    let mode_index = n_grid / 2;
98    let (lo, hi) = interval_bounds(input.latent_grid)?;
99    let topology = ChartTopology::Interval { lo, hi };
100    let latent_coords = input.latent_grid.to_owned();
101
102    let mut trajectories = Vec::with_capacity(n_atoms);
103    for atom in 0..n_atoms {
104        let atom_name = input.atom_names[atom].clone();
105        let mut descriptive_step_changes = Vec::with_capacity(n_checkpoints - 1);
106        let mut transports = Vec::with_capacity(n_checkpoints - 1);
107
108        for step in 0..n_checkpoints - 1 {
109            let c0 = step;
110            let c1 = step + 1;
111
112            // --- transport map across the checkpoint axis --------------------
113            // The chart coordinate is the supplied latent grid itself. Decoder
114            // output components are ambient values and may be non-injective
115            // (for a circle, a component can be cos(t)); they are never used as
116            // latent coordinates.
117            let transport = fit_layer_transport(
118                c0,
119                c1,
120                latent_coords.view(),
121                latent_coords.view(),
122                topology,
123                topology,
124            )
125            .map_err(|e| {
126                format!(
127                    "checkpoint transport for atom '{atom_name}' step {} → {} failed: {e}",
128                    input.checkpoint_ids[c0], input.checkpoint_ids[c1]
129                )
130            })?;
131            transports.push(transport);
132
133            let mut displacement_at_mode = Array1::<f64>::zeros(ambient_dim);
134            let mut grid_sum_sq = 0.0_f64;
135            let mut grid_max_l2 = 0.0_f64;
136            for grid_idx in 0..n_grid {
137                let mut row_sq = 0.0_f64;
138                for component in 0..ambient_dim {
139                    let delta = input.decoder_grid[[c1, atom, grid_idx, component]]
140                        - input.decoder_grid[[c0, atom, grid_idx, component]];
141                    row_sq += delta * delta;
142                    if grid_idx == mode_index {
143                        displacement_at_mode[component] = delta;
144                    }
145                }
146                grid_sum_sq += row_sq;
147                grid_max_l2 = grid_max_l2.max(row_sq.sqrt());
148            }
149            let l2_at_mode = displacement_at_mode.dot(&displacement_at_mode).sqrt();
150            descriptive_step_changes.push(CheckpointStepChange {
151                checkpoint_from: input.checkpoint_ids[c0].clone(),
152                checkpoint_to: input.checkpoint_ids[c1].clone(),
153                latent_coordinate: input.latent_grid[mode_index],
154                displacement_at_mode,
155                l2_at_mode,
156                grid_rms_l2: (grid_sum_sq / n_grid as f64).sqrt(),
157                grid_max_l2,
158            });
159        }
160
161        trajectories.push(AtomTrajectory {
162            atom_name,
163            descriptive_step_changes,
164            transports,
165        });
166    }
167
168    Ok(trajectories)
169}
170
171/// Strict interval bounds for the supplied latent chart.
172fn interval_bounds(grid: ArrayView1<'_, f64>) -> Result<(f64, f64), String> {
173    let lo = grid.iter().copied().fold(f64::INFINITY, f64::min);
174    let hi = grid.iter().copied().fold(f64::NEG_INFINITY, f64::max);
175    if hi <= lo {
176        return Err("checkpoint dynamics latent_grid must have positive range".to_string());
177    }
178    let pad = (hi - lo) * 1e-6;
179    Ok((lo - pad, hi + pad))
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use ndarray::Array4;
186
187    /// Build a `[n_ckpt, n_atoms, n_grid, ambient]` grid where atom 0's curve is
188    /// constant across checkpoints (no change) and atom 1's curve at the central
189    /// (mode) node is displaced by a known amount `shift` in component 0 between
190    /// consecutive checkpoints (a steady drift).
191    fn drift_grid(n_ckpt: usize, n_grid: usize, ambient: usize, shift: f64) -> Array4<f64> {
192        let mode = n_grid / 2;
193        let mut grid = Array4::<f64>::zeros((n_ckpt, 2, n_grid, ambient));
194        for c in 0..n_ckpt {
195            for g in 0..n_grid {
196                let t = g as f64 / (n_grid - 1) as f64;
197                for comp in 0..ambient {
198                    // Atom 0: smooth bump, identical at every checkpoint.
199                    grid[[c, 0, g, comp]] = (t * std::f64::consts::PI).sin() * (comp as f64 + 1.0);
200                    // Atom 1: same base curve plus a checkpoint-indexed shift at
201                    // the mode node in component 0 only.
202                    let base = (t * std::f64::consts::PI).sin() * (comp as f64 + 1.0);
203                    grid[[c, 1, g, comp]] = if g == mode && comp == 0 {
204                        base + shift * c as f64
205                    } else {
206                        base
207                    };
208                }
209            }
210        }
211        grid
212    }
213
214    #[test]
215    fn no_change_atom_has_zero_descriptive_displacement() {
216        let n_ckpt = 5;
217        // The transport fit requires at least MIN_TRANSPORT_OBS (16) paired
218        // grid samples, so the shared latent grid must be at least that long.
219        let n_grid = 17;
220        let ambient = 3;
221        let grid = drift_grid(n_ckpt, n_grid, ambient, 0.5);
222        let latent: Array1<f64> = Array1::linspace(0.0, 1.0, n_grid);
223        let ckpt_ids: Vec<String> = (0..n_ckpt).map(|c| format!("dev{c}")).collect();
224        let atom_names = vec!["constant".to_string(), "drifter".to_string()];
225        let input = CheckpointDynamicsInput {
226            decoder_grid: grid.view(),
227            checkpoint_ids: &ckpt_ids,
228            atom_names: &atom_names,
229            latent_grid: latent.view(),
230        };
231        let traj = checkpoint_atom_dynamics(&input).expect("dynamics");
232        assert_eq!(traj.len(), 2);
233
234        // Atom 0 is identical across checkpoints: every descriptive step change
235        // must be exactly zero displacement (the reported displacement is the
236        // raw decoder-grid difference — no fit, no fabricated SE or e-value).
237        let constant = &traj[0];
238        assert_eq!(constant.descriptive_step_changes.len(), n_ckpt - 1);
239        for change in &constant.descriptive_step_changes {
240            assert_eq!(
241                change.l2_at_mode, 0.0,
242                "constant atom mode displacement must be exactly zero"
243            );
244            assert_eq!(
245                change.grid_rms_l2, 0.0,
246                "constant atom grid displacement must be exactly zero"
247            );
248            assert_eq!(change.grid_max_l2, 0.0);
249        }
250        // The transport across identical checkpoint charts is the identity map
251        // on the shared latent grid — a degree-free, fold-free interval homeo.
252        assert_eq!(constant.transports.len(), n_ckpt - 1);
253    }
254
255    #[test]
256    fn drifting_atom_recovers_exact_descriptive_displacement() {
257        let n_ckpt = 6;
258        let n_grid = 17;
259        let ambient = 3;
260        let shift = 0.7_f64;
261        let grid = drift_grid(n_ckpt, n_grid, ambient, shift);
262        let latent: Array1<f64> = Array1::linspace(0.0, 1.0, n_grid);
263        let ckpt_ids: Vec<String> = (0..n_ckpt).map(|c| format!("dev{c}")).collect();
264        let atom_names = vec!["constant".to_string(), "drifter".to_string()];
265        let input = CheckpointDynamicsInput {
266            decoder_grid: grid.view(),
267            checkpoint_ids: &ckpt_ids,
268            atom_names: &atom_names,
269            latent_grid: latent.view(),
270        };
271        let traj = checkpoint_atom_dynamics(&input).expect("dynamics");
272        let drifter = &traj[1];
273
274        // Each consecutive step displaces component 0 at the mode node by exactly
275        // `shift` and touches no other node/component. The displacement is the
276        // raw decoder-grid difference, so the mode L2 size is exactly `shift`,
277        // and — since only the single mode node moves — the grid RMS over
278        // `n_grid` nodes is `shift / sqrt(n_grid)`.
279        assert_eq!(drifter.descriptive_step_changes.len(), n_ckpt - 1);
280        for change in &drifter.descriptive_step_changes {
281            assert!(
282                (change.l2_at_mode - shift).abs() < 1e-12,
283                "drift mode displacement must equal {shift}, got {}",
284                change.l2_at_mode
285            );
286            // Displacement lives in component 0 only.
287            assert!((change.displacement_at_mode[0] - shift).abs() < 1e-12);
288            for comp in 1..ambient {
289                assert_eq!(change.displacement_at_mode[comp], 0.0);
290            }
291            let expected_rms = shift / (n_grid as f64).sqrt();
292            assert!(
293                (change.grid_rms_l2 - expected_rms).abs() < 1e-12,
294                "drift grid RMS must equal {expected_rms}, got {}",
295                change.grid_rms_l2
296            );
297            assert!((change.grid_max_l2 - shift).abs() < 1e-12);
298        }
299    }
300
301    /// A drifting atom's descriptive displacement must exceed a constant atom's
302    /// (which is exactly zero): the readout is a genuine change discriminator.
303    #[test]
304    fn drift_displacement_exceeds_constant() {
305        let n_ckpt = 6;
306        let n_grid = 17;
307        let ambient = 3;
308        let grid = drift_grid(n_ckpt, n_grid, ambient, 0.7);
309        let latent: Array1<f64> = Array1::linspace(0.0, 1.0, n_grid);
310        let ckpt_ids: Vec<String> = (0..n_ckpt).map(|c| format!("dev{c}")).collect();
311        let atom_names = vec!["constant".to_string(), "drifter".to_string()];
312        let input = CheckpointDynamicsInput {
313            decoder_grid: grid.view(),
314            checkpoint_ids: &ckpt_ids,
315            atom_names: &atom_names,
316            latent_grid: latent.view(),
317        };
318        let traj = checkpoint_atom_dynamics(&input).expect("dynamics");
319        let const_total: f64 = traj[0]
320            .descriptive_step_changes
321            .iter()
322            .map(|c| c.l2_at_mode)
323            .sum();
324        let drift_total: f64 = traj[1]
325            .descriptive_step_changes
326            .iter()
327            .map(|c| c.l2_at_mode)
328            .sum();
329        assert_eq!(const_total, 0.0, "constant atom displacement must be zero");
330        assert!(
331            drift_total > const_total,
332            "drift displacement {drift_total} must exceed constant {const_total}"
333        );
334    }
335
336    #[test]
337    fn rejects_single_checkpoint_and_axis_mismatch() {
338        let grid = Array4::<f64>::zeros((1, 2, 5, 3));
339        let latent: Array1<f64> = Array1::linspace(0.0, 1.0, 5);
340        let ids = vec!["only".to_string()];
341        let names = vec!["a".to_string(), "b".to_string()];
342        let input = CheckpointDynamicsInput {
343            decoder_grid: grid.view(),
344            checkpoint_ids: &ids,
345            atom_names: &names,
346            latent_grid: latent.view(),
347        };
348        assert!(checkpoint_atom_dynamics(&input).is_err());
349    }
350}