gam_sae/inference/steering.rs
1//! `steer_delta` — the **steering primitive with output dosimetry**: the
2//! actionable LLM payload of the SAE-manifold machine.
3//!
4//! # What this computes
5//!
6//! Given a fitted [`SaeManifoldTerm`] and the per-row output-Fisher
7//! [`RowMetric`], a *steering move* is "drive atom `k`'s latent coordinate from
8//! `t_from` to `t_to`". The atom's decoder curve `g_k(t) = Φ_k(t) B_k` maps that
9//! latent move to an **activation-space delta** — the actual vector you add to
10//! the residual stream / reconstruction to realize the move *on the manifold*:
11//!
12//! ```text
13//! δ = a · ( g_k(t_to) − g_k(t_from) ) (the on-manifold move)
14//! ```
15//!
16//! where `a` is the atom's amplitude (how loudly the atom is expressed). This is
17//! the thing a downstream consumer adds to a hidden state.
18//!
19//! # Dosimetry — how big is this push, in nats?
20//!
21//! The headline number is the **predicted output effect**: how much behavioral
22//! change (in nats of KL on the model's output distribution) the move induces.
23//! For a locally-quadratic output readout the KL of a parameter move `Δ` is
24//! `½ Δᵀ F Δ` with `F` the output-Fisher information — exactly the inner product
25//! [`RowMetric`] carries. The dose is the Fisher quadratic form of the move,
26//! **integrated along the decoder curve** rather than read only at the endpoints:
27//!
28//! ```text
29//! predicted_nats = ½ ∫_{t_from}^{t_to} a² · g_k'(t)ᵀ M_n g_k'(t) dt
30//! ```
31//!
32//! evaluated in small steps via the per-row pullback / fisher-mass methods. The
33//! path integral is the honest dose: it follows the curved surface, so a long arc
34//! that doubles back is not under-counted the way a straight endpoint chord would
35//! be.
36//!
37//! # Validity radius — where local linearization stops being trusted
38//!
39//! A consumer must know *how far* the move can be trusted as a linear push. The
40//! **validity radius** is the latent step size at which the path-integrated dose
41//! diverges from the straight endpoint quadratic form
42//! `½ a² δ̂ᵀ M δ̂` (the local-linear prediction) by more than
43//! [`VALIDITY_DIVERGENCE_FRACTION`]. Beyond it the surface has curved enough that
44//! the endpoint chord no longer represents the move. We **report** it; we do not
45//! silently clip to it.
46//!
47//! # Off-manifold guard
48//!
49//! `δ` is, by construction, a chord of the decoder curve, so it should lie in the
50//! atom's local tangent/frame at `t_from` (up to second-order curvature). The
51//! **off-manifold norm** projects `δ` onto the span of the local decoder tangents
52//! `∂g_k/∂t` at `t_from` and reports the residual norm — a self-check that the
53//! steering move stays on the learned surface. It is `≈ 0` for small steps and
54//! grows with arc curvature; a large value means the requested move left the
55//! manifold and the dose number is not to be trusted.
56//!
57//! # Read-only / no loss contact
58//!
59//! This module is a **pure read** over the fitted term and the metric. It calls
60//! only `g_k(t)` evaluation ([`SaeManifoldAtom`]'s decoder + installed
61//! [`SaeBasisEvaluator`]) and the criterion-facing
62//! [`RowMetric::fisher_mass`] / [`RowMetric::pullback`]. It never mutates the
63//! model, never touches a likelihood / criterion / penalty, and the solver floor
64//! `δ` of [`RowMetric`] never enters any number it reports (the fisher-mass /
65//! pullback face is `δ`-free, #747).
66
67use ndarray::{Array1, Array2, ArrayView1};
68
69use crate::encode::EncodeAtlas;
70use crate::manifold::{SaeManifoldTerm, SupportMeasure};
71use gam_problem::{MetricProvenance, RowMetric};
72use gam_terms::inference::structure_evidence::log_e_from_p_calibrator;
73
74/// Number of sub-steps the latent path `[t_from, t_to]` is integrated over for
75/// the dosimetry path integral. The decoder curve is smooth, so a modest
76/// midpoint-rule grid resolves the arc; fixed (no clock / no adaptivity) so the
77/// reported dose is deterministic.
78const STEER_PATH_STEPS: usize = 64;
79
80/// The fraction by which the path-integrated dose may diverge from the straight
81/// endpoint quadratic form before the move is declared past its validity radius.
82/// At `0.1` we trust the linearization while the curved-path dose stays within
83/// 10% of the chord dose.
84const VALIDITY_DIVERGENCE_FRACTION: f64 = 0.1;
85
86/// The actionable output of a steering query over one atom.
87#[derive(Clone, Debug, PartialEq)]
88pub struct SteerPlan {
89 /// Which atom was steered (index into [`SaeManifoldTerm::atoms`]).
90 pub atom: usize,
91 /// The atom's name (mirrors [`crate::manifold::SaeManifoldAtom::name`]).
92 pub atom_name: String,
93 /// The source latent coordinate `t_from` (length = atom's `latent_dim`).
94 pub t_from: Vec<f64>,
95 /// The target latent coordinate `t_to` (length = atom's `latent_dim`).
96 pub t_to: Vec<f64>,
97 /// The amplitude `a` the on-manifold move was scaled by (the atom's mean
98 /// active assignment mass; `1.0` if the atom is active on no row).
99 pub amplitude: f64,
100 /// The row whose per-row output-Fisher metric the dose was measured through
101 /// (the atom's most-active row; `0` if active nowhere).
102 pub measured_row: usize,
103 /// **The activation-space delta**: `δ = a · (g_k(t_to) − g_k(t_from))`, a
104 /// length-`p` vector in the reconstruction/output space — the actual move to
105 /// add to a hidden state.
106 pub delta: Array1<f64>,
107 /// **DOSIMETRY**: predicted output effect of the move in **nats** of KL,
108 /// integrated along the decoder curve through the output-Fisher metric.
109 /// `None` when the metric carries no behavioral information (Euclidean
110 /// provenance) — the dose is *not available*, not zero.
111 pub predicted_nats: Option<f64>,
112 /// **VALIDITY RADIUS**: the latent step size (Euclidean norm of the move from
113 /// `t_from`) at which the path-integrated dose first diverges from the
114 /// straight endpoint quadratic form by more than
115 /// [`VALIDITY_DIVERGENCE_FRACTION`]. Equals the full move length when the
116 /// linearization is trusted all the way to `t_to`. `None` under a no-behavior
117 /// metric (there is no dose to validate).
118 pub validity_radius: Option<f64>,
119 /// **OFF-MANIFOLD GUARD**: the norm of `δ`'s component outside the span of
120 /// the atom's local decoder tangents `∂g_k/∂t` at `t_from`. `≈ 0` by
121 /// construction (the move is a chord of the curve); a large value flags a
122 /// move that left the learned surface.
123 pub off_manifold_norm: f64,
124 /// The provenance of the metric the dose was read through, echoed so a
125 /// consumer can certify *why* `predicted_nats` is `None` when it is.
126 pub metric_provenance: MetricProvenance,
127}
128
129/// Result of writing one certified chart coordinate into an activation row.
130///
131/// The edited row is always `x + δ`, where `δ` is the delta returned by
132/// [`steer_delta`] for the atom's current encoded coordinate and the requested
133/// target coordinate. Because only the on-manifold atom chord is added, every
134/// component of `x` outside this atom's chart residual is preserved exactly; this
135/// is the locality guarantee missing from whole-residual linear-steering
136/// baselines.
137#[derive(Clone, Debug)]
138pub struct CoordinateSetResult {
139 /// The edited activation/reconstruction row.
140 pub edited: Array1<f64>,
141 /// Certified coordinate read from the input row before the write.
142 pub t_from_certified: Array1<f64>,
143 /// Certificate attached to `t_from_certified`.
144 pub encode_certificate: crate::encode::RowCertificate,
145 /// Steering plan whose `delta` was added to the row.
146 pub steer: SteerPlan,
147}
148
149/// Write atom `atom_k`'s chart coordinate in row `x` to `t_to` by delta
150/// steering, preserving the row's off-atom/off-subspace residual exactly.
151///
152/// `amplitude` is the assignment/intensity with which the row expresses this
153/// atom; callers that have already separated existence/intensity/position should
154/// pass the intensity and only swap the position coordinate. The certified read
155/// uses [`EncodeAtlas::certified_encode_row`]; the write uses [`steer_delta`].
156pub fn set_coordinate(
157 model: &SaeManifoldTerm,
158 metric: &RowMetric,
159 atlas: &EncodeAtlas,
160 x: ArrayView1<'_, f64>,
161 atom_k: usize,
162 amplitude: f64,
163 t_to: &[f64],
164) -> Result<CoordinateSetResult, String> {
165 let atom = model.atoms.get(atom_k).ok_or_else(|| {
166 format!(
167 "set_coordinate: atom index {atom_k} out of range (term has {} atoms)",
168 model.k_atoms()
169 )
170 })?;
171 if x.len() != atom.output_dim() {
172 return Err(format!(
173 "set_coordinate: input row has length {} but atom {atom_k} output_dim is {}",
174 x.len(),
175 atom.output_dim()
176 ));
177 }
178 let (t_from, cert) = atlas.certified_encode_row(atom, atom_k, x, amplitude)?;
179 let steer = steer_delta_with_amplitude(
180 model,
181 metric,
182 atom_k,
183 t_from.as_slice().unwrap_or(&[]),
184 t_to,
185 amplitude,
186 )?;
187 let mut edited = x.to_owned();
188 if edited.len() != steer.delta.len() {
189 return Err(format!(
190 "set_coordinate: steering delta length {} does not match row length {}",
191 steer.delta.len(),
192 edited.len()
193 ));
194 }
195 for i in 0..edited.len() {
196 edited[i] += steer.delta[i];
197 }
198 Ok(CoordinateSetResult {
199 edited,
200 t_from_certified: t_from,
201 encode_certificate: cert,
202 steer,
203 })
204}
205
206/// Result of a coordinate interchange: donor position read from `x_source`, then
207/// written into `x_target` while preserving the target residual and intensity.
208#[derive(Clone, Debug)]
209pub struct InterchangeResult {
210 /// Target row after the donor coordinate has been delta-written into it.
211 pub edited_target: Array1<f64>,
212 /// Donor/source coordinate that was transplanted.
213 pub donor_t: Array1<f64>,
214 /// Target coordinate before the transplant.
215 pub target_t_before: Array1<f64>,
216 /// Target behavior coordinate after the transplant, re-read from the edit.
217 pub target_t_after: Array1<f64>,
218 /// Steering dose in nats, when a behavioral metric is available.
219 pub predicted_nats: Option<f64>,
220 /// Norm of the steering delta outside the local atom tangent frame.
221 pub off_manifold_norm: f64,
222 /// Reported steering validity radius.
223 pub validity_radius: Option<f64>,
224 /// Calibrated log e-value for counterfactual consistency: larger means the
225 /// post-edit target coordinate landed closer to the donor coordinate.
226 pub counterfactual_consistency_log_e: f64,
227 /// Underlying coordinate-write plan.
228 pub set_result: CoordinateSetResult,
229}
230
231/// Interchange atom `atom_k`'s chart coordinate from `x_source` into `x_target`.
232///
233/// The source coordinate is certified with `source_amplitude`; the target write
234/// is performed with `target_amplitude`, so swapping a position coordinate cannot
235/// silently smuggle donor intensity into the target. The returned consistency
236/// e-value is computed by re-encoding the edited target and calibrating the
237/// coordinate landing error into the existing structure-evidence e-currency.
238pub fn interchange(
239 model: &SaeManifoldTerm,
240 metric: &RowMetric,
241 atlas: &EncodeAtlas,
242 x_target: ArrayView1<'_, f64>,
243 target_amplitude: f64,
244 x_source: ArrayView1<'_, f64>,
245 source_amplitude: f64,
246 atom_k: usize,
247) -> Result<InterchangeResult, String> {
248 let atom = model.atoms.get(atom_k).ok_or_else(|| {
249 format!(
250 "interchange: atom index {atom_k} out of range (term has {} atoms)",
251 model.k_atoms()
252 )
253 })?;
254 let (donor_t, _donor_cert) =
255 atlas.certified_encode_row(atom, atom_k, x_source, source_amplitude)?;
256 let set = set_coordinate(
257 model,
258 metric,
259 atlas,
260 x_target,
261 atom_k,
262 target_amplitude,
263 donor_t.as_slice().unwrap_or(&[]),
264 )?;
265 let (target_t_after, _after_cert) =
266 atlas.certified_encode_row(atom, atom_k, set.edited.view(), target_amplitude)?;
267 let landing_error = l2_distance(donor_t.view(), target_t_after.view())?;
268 let scale = set
269 .steer
270 .validity_radius
271 .unwrap_or_else(|| {
272 l2_distance(set.t_from_certified.view(), donor_t.view())
273 .unwrap_or(1.0)
274 .max(1e-12)
275 })
276 .max(1e-12);
277 // Convert closeness into a superuniform-shaped p-value and then into the
278 // repository's standard e-value currency. Exact hits approach machine-small
279 // p-values; errors at/above the validity radius produce e-values near or
280 // below one, so shuffled-chart negative controls do not accumulate evidence.
281 let z = (scale / landing_error.max(1e-12)).min(1.0e6);
282 let p_value = (-0.5 * z * z).exp().clamp(f64::MIN_POSITIVE, 1.0);
283 let log_e = log_e_from_p_calibrator(p_value)?;
284 Ok(InterchangeResult {
285 edited_target: set.edited.clone(),
286 donor_t,
287 target_t_before: set.t_from_certified.clone(),
288 target_t_after,
289 predicted_nats: set.steer.predicted_nats,
290 off_manifold_norm: set.steer.off_manifold_norm,
291 validity_radius: set.steer.validity_radius,
292 counterfactual_consistency_log_e: log_e,
293 set_result: set,
294 })
295}
296
297fn l2_distance(a: ArrayView1<'_, f64>, b: ArrayView1<'_, f64>) -> Result<f64, String> {
298 if a.len() != b.len() {
299 return Err(format!(
300 "coordinate distance length mismatch: {} vs {}",
301 a.len(),
302 b.len()
303 ));
304 }
305 let mut ss = 0.0;
306 for i in 0..a.len() {
307 let r = a[i] - b[i];
308 ss += r * r;
309 }
310 Ok(ss.sqrt())
311}
312
313/// Build a [`SteerPlan`] for driving atom `atom_k` from `t_from` to `t_to`.
314///
315/// `model` is the fitted term (read only); `metric` is the per-row output-Fisher
316/// inner product the dose is measured through (typically `model.row_metric()`'s
317/// own metric, or any metric whose row/output dims match the term). `t_from` and
318/// `t_to` are latent coordinates of length `atom.latent_dim`.
319///
320/// Errors when the atom index is out of range, the coordinate lengths do not
321/// match the atom's latent dimension, the atom has no installed
322/// [`crate::manifold::SaeBasisEvaluator`] (arbitrary-`t` evaluation
323/// requires one), or the metric dimensions do not match the term. Under a
324/// Euclidean (no-behavior) metric the geometry is still produced but
325/// `predicted_nats` / `validity_radius` degrade to `None`.
326pub fn steer_delta(
327 model: &SaeManifoldTerm,
328 metric: &RowMetric,
329 atom_k: usize,
330 t_from: &[f64],
331 t_to: &[f64],
332) -> Result<SteerPlan, String> {
333 steer_delta_impl(model, metric, atom_k, t_from, t_to, None)
334}
335
336fn steer_delta_with_amplitude(
337 model: &SaeManifoldTerm,
338 metric: &RowMetric,
339 atom_k: usize,
340 t_from: &[f64],
341 t_to: &[f64],
342 amplitude: f64,
343) -> Result<SteerPlan, String> {
344 if !(amplitude.is_finite() && amplitude > 0.0) {
345 return Err(format!(
346 "steer_delta_with_amplitude: amplitude must be finite and positive, got {amplitude}"
347 ));
348 }
349 steer_delta_impl(model, metric, atom_k, t_from, t_to, Some(amplitude))
350}
351
352fn steer_delta_impl(
353 model: &SaeManifoldTerm,
354 metric: &RowMetric,
355 atom_k: usize,
356 t_from: &[f64],
357 t_to: &[f64],
358 amplitude_override: Option<f64>,
359) -> Result<SteerPlan, String> {
360 let k = model.k_atoms();
361 if atom_k >= k {
362 return Err(format!(
363 "steer_delta: atom index {atom_k} out of range (term has {k} atoms)"
364 ));
365 }
366 let atom = &model.atoms[atom_k];
367 let d = atom.latent_dim;
368 let p = atom.output_dim();
369 if t_from.len() != d || t_to.len() != d {
370 return Err(format!(
371 "steer_delta: t_from/t_to must have length latent_dim={d}; got {} and {}",
372 t_from.len(),
373 t_to.len()
374 ));
375 }
376 let evaluator = atom.basis_evaluator.as_ref().ok_or_else(|| {
377 format!(
378 "steer_delta: atom {atom_k} ('{}') has no installed basis evaluator; \
379 arbitrary-t decoder evaluation requires one",
380 atom.name
381 )
382 })?;
383
384 // --- amplitude & the row the dose is measured through -------------------
385 // The amplitude and measured row come from the shared atom support measure.
386 // Hard 0/1 support gives amplitude 1 on non-empty support, matching the old
387 // active-mask limit; diffuse support scales by its support-weighted mass.
388 let support = SupportMeasure::from_assignment(&model.assignment, atom_k)?;
389 let n = model.n_obs();
390 let mut best_row = 0usize;
391 let mut best_mass = f64::NEG_INFINITY;
392 for row in 0..support.len() {
393 let mass = support.weight(row);
394 if mass > best_mass {
395 best_mass = mass;
396 best_row = row;
397 }
398 }
399 let amplitude = amplitude_override.unwrap_or_else(|| {
400 if support.mass() > 0.0 {
401 support.fisher_n() / support.mass()
402 } else {
403 0.0
404 }
405 });
406
407 // --- the on-manifold activation-space delta -----------------------------
408 let g_from = decode_at(evaluator.as_ref(), &atom.decoder_coefficients, t_from, p)?;
409 let g_to = decode_at(evaluator.as_ref(), &atom.decoder_coefficients, t_to, p)?;
410 let mut delta = Array1::<f64>::zeros(p);
411 for i in 0..p {
412 delta[i] = amplitude * (g_to[i] - g_from[i]);
413 }
414
415 // Whether the metric can/does match this term and carries behavior.
416 let provenance = metric.provenance();
417 let behavior_available =
418 metric_carries_behavior(provenance) && metric.n_rows() == n && metric.p_out() == p;
419
420 // --- off-manifold guard -------------------------------------------------
421 // Project δ onto the span of the local decoder tangents ∂g_k/∂t and report
422 // the residual norm. The tangents are evaluated at the move's MIDPOINT, not
423 // at t_from: the chord of a curve is symmetric about its midpoint, so its
424 // component transverse to the midpoint tangent is the true second-order
425 // sagitta (`O(‖Δt‖²)`), whereas the endpoint tangent differs from the chord
426 // direction already at first order. Measuring against the midpoint frame is
427 // therefore the honest "did the move stay on the surface" self-check: it is
428 // `≈ 0` for an on-manifold move and grows only with genuine arc curvature.
429 let mut t_mid = vec![0.0_f64; d];
430 for a in 0..d {
431 t_mid[a] = 0.5 * (t_from[a] + t_to[a]);
432 }
433 let tangents =
434 decode_tangents_at(evaluator.as_ref(), &atom.decoder_coefficients, &t_mid, p, d)?;
435 let off_manifold_norm = off_manifold_residual_norm(&tangents, delta.view());
436
437 // --- dosimetry: path-integrated Fisher dose -----------------------------
438 let (predicted_nats, validity_radius) = if !behavior_available {
439 (None, None)
440 } else {
441 let ctx = SteerContext {
442 evaluator: evaluator.as_ref(),
443 decoder: &atom.decoder_coefficients,
444 metric,
445 row: best_row,
446 p,
447 d,
448 amplitude,
449 };
450 let dose = path_integrated_dose(&ctx, t_from, t_to)?;
451 let radius = validity_radius(&ctx, t_from, t_to)?;
452 (Some(dose), Some(radius))
453 };
454
455 Ok(SteerPlan {
456 atom: atom_k,
457 atom_name: atom.name.clone(),
458 t_from: t_from.to_vec(),
459 t_to: t_to.to_vec(),
460 amplitude,
461 measured_row: best_row,
462 delta,
463 predicted_nats,
464 validity_radius,
465 off_manifold_norm,
466 metric_provenance: provenance,
467 })
468}
469
470/// The model's predicted output-mean response to an applied activation push
471/// `δ`, under the LOCAL-LINEAR reading of its fitted surface: the projection
472/// of `δ` onto the span of atom `atom_k`'s decoder tangents `∂g_k/∂t` at the
473/// operating point `t_at`. A dictionary "predicts" exactly the component of a
474/// push it can carry along its learned surface; the transverse component is
475/// off-manifold and predicted to die (this is the same local model the
476/// off-manifold guard and the dosimetry chord trust, used in the same radius).
477///
478/// This is `μ(δ)` for the design loop of
479/// [`gam_terms::inference::structure_evidence`]: two structural hypotheses about
480/// the same activations (e.g. "one curved atom" vs "two flat atoms") are two
481/// fitted terms whose tangent spans differ, so they predict DIFFERENT
482/// responses to the same probe — and that disagreement, in the output-Fisher
483/// metric, is what `select_probe_by_expected_evidence` maximizes.
484pub fn predicted_response(
485 model: &SaeManifoldTerm,
486 atom_k: usize,
487 t_at: &[f64],
488 delta: ArrayView1<'_, f64>,
489) -> Result<Array1<f64>, String> {
490 let k = model.k_atoms();
491 if atom_k >= k {
492 return Err(format!(
493 "predicted_response: atom index {atom_k} out of range (term has {k} atoms)"
494 ));
495 }
496 let atom = &model.atoms[atom_k];
497 let d = atom.latent_dim;
498 let p = atom.output_dim();
499 if t_at.len() != d {
500 return Err(format!(
501 "predicted_response: t_at must have length latent_dim={d}; got {}",
502 t_at.len()
503 ));
504 }
505 if delta.len() != p {
506 return Err(format!(
507 "predicted_response: delta must have length output_dim={p}; got {}",
508 delta.len()
509 ));
510 }
511 let evaluator = atom.basis_evaluator.as_ref().ok_or_else(|| {
512 format!(
513 "predicted_response: atom {atom_k} ('{}') has no installed basis evaluator",
514 atom.name
515 )
516 })?;
517 let tangents = decode_tangents_at(evaluator.as_ref(), &atom.decoder_coefficients, t_at, p, d)?;
518 Ok(project_onto_tangent_span(&tangents, delta))
519}
520
521/// Does this provenance carry behavioral (output-Fisher) information? Euclidean
522/// is the isotropic activation-only path and carries none; the factored
523/// provenances do. (Mirrors `atom_lens::metric_carries_behavior`.)
524fn metric_carries_behavior(p: MetricProvenance) -> bool {
525 match p {
526 MetricProvenance::Euclidean => false,
527 MetricProvenance::OutputFisher { .. }
528 | MetricProvenance::OutputFisherDownstream { .. }
529 | MetricProvenance::BehavioralFisher { .. }
530 | MetricProvenance::WhitenedStructured { .. } => true,
531 }
532}
533
534/// Evaluate the decoder output `g_k(t) = Φ_k(t) B_k ∈ ℝ^p` at an arbitrary
535/// latent coordinate `t` (length `d`) via the atom's installed evaluator.
536fn decode_at(
537 evaluator: &dyn crate::manifold::SaeBasisEvaluator,
538 decoder: &Array2<f64>,
539 t: &[f64],
540 p: usize,
541) -> Result<Array1<f64>, String> {
542 let d = t.len();
543 let coords = Array2::from_shape_vec((1, d), t.to_vec())
544 .map_err(|e| format!("steer_delta::decode_at: coord shape: {e}"))?;
545 let (phi, _jet) = evaluator.evaluate(coords.view())?;
546 let m = decoder.nrows();
547 if phi.ncols() != m {
548 return Err(format!(
549 "steer_delta::decode_at: evaluator returned {} basis cols but decoder has {m} rows",
550 phi.ncols()
551 ));
552 }
553 let mut g = Array1::<f64>::zeros(p);
554 for basis_col in 0..m {
555 let phi_v = phi[[0, basis_col]];
556 if phi_v == 0.0 {
557 continue;
558 }
559 for out_col in 0..p {
560 g[out_col] += phi_v * decoder[[basis_col, out_col]];
561 }
562 }
563 Ok(g)
564}
565
566/// Evaluate the decoder tangents `∂g_k/∂t_a = Φ_k'(t) B_k ∈ ℝ^p`, one per latent
567/// axis `a ∈ 0..d`, at an arbitrary latent coordinate `t`. Returned as a
568/// `(p × d)` matrix whose column `a` is the tangent along axis `a`.
569fn decode_tangents_at(
570 evaluator: &dyn crate::manifold::SaeBasisEvaluator,
571 decoder: &Array2<f64>,
572 t: &[f64],
573 p: usize,
574 d: usize,
575) -> Result<Array2<f64>, String> {
576 let coords = Array2::from_shape_vec((1, d), t.to_vec())
577 .map_err(|e| format!("steer_delta::decode_tangents_at: coord shape: {e}"))?;
578 let (_phi, jet) = evaluator.evaluate(coords.view())?;
579 let m = decoder.nrows();
580 if jet.dim() != (1, m, d) {
581 return Err(format!(
582 "steer_delta::decode_tangents_at: evaluator jet {:?} != (1, {m}, {d})",
583 jet.dim()
584 ));
585 }
586 let mut tang = Array2::<f64>::zeros((p, d));
587 for axis in 0..d {
588 for basis_col in 0..m {
589 let dphi = jet[[0, basis_col, axis]];
590 if dphi == 0.0 {
591 continue;
592 }
593 for out_col in 0..p {
594 tang[[out_col, axis]] += dphi * decoder[[basis_col, out_col]];
595 }
596 }
597 }
598 Ok(tang)
599}
600
601/// Least-squares projection of `δ` onto the span of the local tangents
602/// (columns of `tangents`, shape `p × d`): `δ̂ = T (TᵀT)⁻¹ Tᵀ δ` via a small
603/// `d × d` Gram solve (with a tiny diagonal jitter to absorb a rank-deficient
604/// tangent frame; the jitter only shrinks the projection, never inflates it).
605fn project_onto_tangent_span(tangents: &Array2<f64>, delta: ArrayView1<'_, f64>) -> Array1<f64> {
606 let p = tangents.nrows();
607 let d = tangents.ncols();
608 if d == 0 {
609 return Array1::<f64>::zeros(p);
610 }
611 // Gram = TᵀT (d × d) and rhs = Tᵀδ (d).
612 let mut gram = Array2::<f64>::zeros((d, d));
613 let mut rhs = Array1::<f64>::zeros(d);
614 for a in 0..d {
615 let mut r = 0.0_f64;
616 for i in 0..p {
617 r += tangents[[i, a]] * delta[i];
618 }
619 rhs[a] = r;
620 for b in a..d {
621 let mut acc = 0.0_f64;
622 for i in 0..p {
623 acc += tangents[[i, a]] * tangents[[i, b]];
624 }
625 gram[[a, b]] = acc;
626 gram[[b, a]] = acc;
627 }
628 }
629 let trace: f64 = (0..d).map(|a| gram[[a, a]]).sum();
630 let jitter = if trace > 0.0 { 1e-12 * trace } else { 1e-12 };
631 for a in 0..d {
632 gram[[a, a]] += jitter;
633 }
634 let coeffs = solve_spd_small(&gram, &rhs);
635 let mut proj = Array1::<f64>::zeros(p);
636 for i in 0..p {
637 for a in 0..d {
638 proj[i] += tangents[[i, a]] * coeffs[a];
639 }
640 }
641 proj
642}
643
644/// Norm of `δ`'s component orthogonal to the span of the local tangents:
645/// `‖δ − δ̂‖` with `δ̂` the [`project_onto_tangent_span`] projection.
646fn off_manifold_residual_norm(tangents: &Array2<f64>, delta: ArrayView1<'_, f64>) -> f64 {
647 let proj = project_onto_tangent_span(tangents, delta);
648 let mut res_sq = 0.0_f64;
649 for i in 0..delta.len() {
650 let r = delta[i] - proj[i];
651 res_sq += r * r;
652 }
653 res_sq.max(0.0).sqrt()
654}
655
656/// Tiny symmetric-positive-definite solve via Cholesky for the `d × d` tangent
657/// Gram (`d` is the atom's latent dim, typically 1–3). Falls back to the bare rhs
658/// if the factorization fails (a fully degenerate frame), which only inflates the
659/// reported off-manifold residual — never deflates it.
660fn solve_spd_small(gram: &Array2<f64>, rhs: &Array1<f64>) -> Array1<f64> {
661 let d = gram.nrows();
662 // Cholesky L LᵀT = gram.
663 let mut l = Array2::<f64>::zeros((d, d));
664 for i in 0..d {
665 for j in 0..=i {
666 let mut sum = gram[[i, j]];
667 for k in 0..j {
668 sum -= l[[i, k]] * l[[j, k]];
669 }
670 if i == j {
671 if sum <= 0.0 {
672 return Array1::<f64>::zeros(d);
673 }
674 l[[i, j]] = sum.sqrt();
675 } else {
676 l[[i, j]] = sum / l[[j, j]];
677 }
678 }
679 }
680 // Forward solve L y = rhs.
681 let mut y = Array1::<f64>::zeros(d);
682 for i in 0..d {
683 let mut sum = rhs[i];
684 for k in 0..i {
685 sum -= l[[i, k]] * y[k];
686 }
687 y[i] = sum / l[[i, i]];
688 }
689 // Back solve Lᵀ x = y.
690 let mut x = Array1::<f64>::zeros(d);
691 for i in (0..d).rev() {
692 let mut sum = y[i];
693 for k in (i + 1)..d {
694 sum -= l[[k, i]] * x[k];
695 }
696 x[i] = sum / l[[i, i]];
697 }
698 x
699}
700
701/// The fixed geometry of one steering query, bundled so the dose integrator and
702/// its helpers take a single context rather than a long argument list.
703struct SteerContext<'a> {
704 evaluator: &'a dyn crate::manifold::SaeBasisEvaluator,
705 decoder: &'a Array2<f64>,
706 metric: &'a RowMetric,
707 /// The row whose per-row metric the dose is measured through.
708 row: usize,
709 /// Output dimension `p`.
710 p: usize,
711 /// Latent dimension `d`.
712 d: usize,
713 /// Amplitude `a` the move is scaled by.
714 amplitude: f64,
715}
716
717/// Path-integrated Fisher dose
718/// `½ a² ∫ g_k'(t)ᵀ M g_k'(t) dt` along the straight latent segment
719/// `t(τ) = t_from + τ (t_to − t_from)`, `τ ∈ [0, 1]`, by the midpoint rule over
720/// [`STEER_PATH_STEPS`] sub-steps.
721///
722/// The local quadratic `g'(t)ᵀ M g'(t)` is the [`RowMetric::pullback`] of the
723/// per-axis decoder tangents contracted with the latent velocity `Δt`, so this
724/// uses only the criterion-facing pullback (no loss / no solver floor).
725fn path_integrated_dose(
726 ctx: &SteerContext<'_>,
727 t_from: &[f64],
728 t_to: &[f64],
729) -> Result<f64, String> {
730 let d = ctx.d;
731 let p = ctx.p;
732 let steps = STEER_PATH_STEPS;
733 let dtau = 1.0 / steps as f64;
734 // Latent velocity Δt (constant along the straight segment).
735 let mut dt = vec![0.0_f64; d];
736 for a in 0..d {
737 dt[a] = t_to[a] - t_from[a];
738 }
739 let mut acc = 0.0_f64;
740 let amp2 = ctx.amplitude * ctx.amplitude;
741 for s in 0..steps {
742 // Midpoint of sub-step s in τ, mapped to a latent coordinate.
743 let tau_mid = (s as f64 + 0.5) * dtau;
744 let mut t_mid = vec![0.0_f64; d];
745 for a in 0..d {
746 t_mid[a] = t_from[a] + tau_mid * dt[a];
747 }
748 // Decoder tangents at the midpoint: ∂g/∂t_a, columns of a (p × d) matrix.
749 let tang = decode_tangents_at(ctx.evaluator, ctx.decoder, &t_mid, p, d)?;
750 // The pulled-back metric at this point is g_{ab} = (∂g/∂t)ᵀ M (∂g/∂t),
751 // the d × d local inner product of latent motion *in output-Fisher
752 // units*. We form it through the criterion-facing `RowMetric::pullback`
753 // (which never materializes the p × p M and never sees the solver δ),
754 // then contract the latent velocity Δt twice: the squared output-Fisher
755 // speed along the path is Δtᵀ g Δt. The decoder Jacobian is passed flat
756 // row-major (J[i, a] = j_row[i * d + a]) as `pullback` expects.
757 let mut j_row = vec![0.0_f64; p * d];
758 for i in 0..p {
759 for a in 0..d {
760 j_row[i * d + a] = tang[[i, a]];
761 }
762 }
763 let g_ab = ctx.metric.pullback(ctx.row, &j_row, d);
764 let mut speed_sq = 0.0_f64;
765 for a in 0..d {
766 for b in 0..d {
767 speed_sq += dt[a] * g_ab[[a, b]] * dt[b];
768 }
769 }
770 acc += 0.5 * amp2 * speed_sq * dtau;
771 }
772 Ok(acc)
773}
774
775/// The validity radius: the latent step length (Euclidean distance from
776/// `t_from`) at which **local linearization stops being trusted**.
777///
778/// Linearizing the steering move means predicting the output effect of a prefix
779/// step `τ·Δt` from the initial tangent alone: the first-order output move is
780/// `δ_lin(τ) = a · (∂g/∂t|_{t_from}) · (τ Δt)`, whose output-Fisher KL is the
781/// quadratic form `½ ‖δ_lin(τ)‖²_M = τ² · ½ a² ‖∂g/∂t·Δt‖²_M`. The **true**
782/// effect of that prefix is the chord quadratic form of the *actual* curved
783/// output move `½ a² ‖g(t_from + τΔt) − g(t_from)‖²_M`.
784///
785/// The radius is the chord length `τ* · ‖Δt‖` at the first prefix `τ*` where the
786/// true chord KL diverges from the linear prediction by more than
787/// [`VALIDITY_DIVERGENCE_FRACTION`] (relative to the linear prediction). This is
788/// pure surface curvature: on a flat decoder the two agree for every `τ` and the
789/// radius is the whole move. If the metric kills the tangent (no linear effect to
790/// validate), the move is trusted to its full length.
791fn validity_radius(ctx: &SteerContext<'_>, t_from: &[f64], t_to: &[f64]) -> Result<f64, String> {
792 let d = ctx.d;
793 let p = ctx.p;
794 let full_len: f64 = t_from
795 .iter()
796 .zip(t_to.iter())
797 .map(|(&a, &b)| (b - a) * (b - a))
798 .sum::<f64>()
799 .sqrt();
800 if full_len == 0.0 {
801 return Ok(0.0);
802 }
803 let mut dt = vec![0.0_f64; d];
804 for a in 0..d {
805 dt[a] = t_to[a] - t_from[a];
806 }
807 let amp = ctx.amplitude;
808
809 // Initial-tangent linear output move per unit τ: v0 = (∂g/∂t|_{t_from}) Δt.
810 let tang0 = decode_tangents_at(ctx.evaluator, ctx.decoder, t_from, p, d)?;
811 let mut v0 = Array1::<f64>::zeros(p);
812 for i in 0..p {
813 let mut acc = 0.0_f64;
814 for a in 0..d {
815 acc += tang0[[i, a]] * dt[a];
816 }
817 v0[i] = acc;
818 }
819 // ½ a² ‖v0‖²_M — the per-τ² linear KL coefficient.
820 let lin_coeff = 0.5 * amp * amp * ctx.metric.fisher_mass(ctx.row, v0.view());
821 // No linear effect to validate against ⇒ trust the full move.
822 if !(lin_coeff > 0.0) {
823 return Ok(full_len);
824 }
825
826 let g_from = decode_at(ctx.evaluator, ctx.decoder, t_from, p)?;
827 let steps = STEER_PATH_STEPS;
828 for s in 0..steps {
829 let tau = (s as f64 + 1.0) / steps as f64;
830 let mut t_mid = vec![0.0_f64; d];
831 for a in 0..d {
832 t_mid[a] = t_from[a] + tau * dt[a];
833 }
834 let g_tau = decode_at(ctx.evaluator, ctx.decoder, &t_mid, p)?;
835 let mut chord = Array1::<f64>::zeros(p);
836 for i in 0..p {
837 chord[i] = amp * (g_tau[i] - g_from[i]);
838 }
839 // True chord KL of the prefix, and the linear prediction τ²·lin_coeff.
840 let chord_kl = 0.5 * ctx.metric.fisher_mass(ctx.row, chord.view());
841 let lin_kl = tau * tau * lin_coeff;
842 let rel = (chord_kl - lin_kl).abs() / lin_kl;
843 if rel > VALIDITY_DIVERGENCE_FRACTION {
844 return Ok(tau * full_len);
845 }
846 }
847 Ok(full_len)
848}