1use crate::inference::layer_transport::{ChartTopology, LayerTransportReport, fit_layer_transport};
11use ndarray::{Array1, ArrayView1, ArrayView4};
12
13pub 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
25pub struct CheckpointStepChange {
27 pub checkpoint_from: String,
28 pub checkpoint_to: String,
29 pub latent_coordinate: f64,
30 pub displacement_at_mode: Array1<f64>,
32 pub l2_at_mode: f64,
33 pub grid_rms_l2: f64,
35 pub grid_max_l2: f64,
37}
38
39pub struct AtomTrajectory {
41 pub atom_name: String,
42 pub descriptive_step_changes: Vec<CheckpointStepChange>,
43 pub transports: Vec<LayerTransportReport>,
46}
47
48pub 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 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 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
171fn 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 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 grid[[c, 0, g, comp]] = (t * std::f64::consts::PI).sin() * (comp as f64 + 1.0);
200 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 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 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 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 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 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 #[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}