Skip to main content

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}