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//! Here `g_k(t) = Phi_k^eta(t) B_k` is the fitted physical decoder, including
12//! the curvature-homotopy state:
13//!
14//! ```text
15//! delta = a · ( g_k(t_to) - g_k(t_from) )      (the on-manifold move)
16//! ```
17//!
18//! where `a` is the atom's amplitude (how loudly the atom is expressed). This is
19//! the thing a downstream consumer adds to a hidden state.
20//!
21//! # Dosimetry — how big is this push, in nats?
22//!
23//! The headline number is the **predicted output effect**: how much behavioral
24//! change (in nats of KL on the model's output distribution) the exact applied
25//! activation move induces. For a locally-quadratic output readout the KL of a
26//! move `delta` is `0.5 * delta^T F delta`, with `F` the output-Fisher
27//! information — exactly the inner product [`RowMetric`] carries:
28//!
29//! ```text
30//! predicted_nats = 0.5 * delta^T M_metric_row delta
31//! ```
32//!
33//! This endpoint quadratic form is the single canonical nats prediction because
34//! it prices the same `delta` a patched forward pass applies. Arc energy and
35//! tangent-only surrogates are deliberately not exposed as alternate nats lanes:
36//! they price different objects and therefore cannot be calibrated against the
37//! patched-forward endpoint KL by construction (#2249).
38//!
39//! # Validity radius — where local linearization stops being trusted
40//!
41//! A consumer must know *how far* the move can be trusted as a linear push. The
42//! **validity radius** is the latent step size at which the exact chord dose
43//! diverges from the initial-tangent quadratic prediction by more than
44//! [`VALIDITY_DIVERGENCE_FRACTION`]. Beyond it the surface has curved enough that
45//! the endpoint chord no longer represents the move. We **report** it; we do not
46//! silently clip to it.
47//!
48//! # Off-manifold guard
49//!
50//! `δ` is, by construction, a chord of the decoder curve, so it should lie in the
51//! atom's local tangent/frame at `t_from` (up to second-order curvature). The
52//! **off-manifold norm** projects `δ` onto the span of the local decoder tangents
53//! `∂g_k/∂t` at `t_from` and reports the residual norm — a self-check that the
54//! steering move stays on the learned surface. It is `≈ 0` for small steps and
55//! grows with arc curvature; a large value means the requested move left the
56//! manifold and the dose number is not to be trusted.
57//!
58//! # Read-only / no loss contact
59//!
60//! This module is a **pure read** over the fitted term and the metric. It calls
61//! only `g_k(t)` evaluation ([`SaeManifoldAtom`]'s decoder + installed
62//! [`SaeBasisEvaluator`]) and the criterion-facing
63//! [`RowMetric::fisher_mass`] / [`RowMetric::pullback`]. It never mutates the
64//! model, never touches a likelihood / criterion / penalty, and the solver floor
65//! `δ` of [`RowMetric`] never enters any number it reports (the fisher-mass /
66//! pullback face is `δ`-free, #747).
67
68use ndarray::{Array1, Array2, ArrayView1};
69
70use crate::encode::EncodeAtlas;
71use crate::manifold::{SaeManifoldAtom, SaeManifoldTerm};
72use gam_problem::{MetricProvenance, RowMetric};
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_VALIDITY_STEPS: usize = 64;
79
80/// The fraction by which the exact chord dose may diverge from the
81/// initial-tangent quadratic prediction before the move is declared past its
82/// validity radius.
83const VALIDITY_DIVERGENCE_FRACTION: f64 = 0.1;
84
85/// The actionable output of a steering query over one atom.
86#[derive(Clone, Debug, PartialEq)]
87pub struct SteerPlan {
88    /// Which atom was steered (index into [`SaeManifoldTerm::atoms`]).
89    pub atom: usize,
90    /// The atom's name (mirrors [`crate::manifold::SaeManifoldAtom::name`]).
91    pub atom_name: String,
92    /// The source latent coordinate `t_from` (length = atom's `latent_dim`).
93    pub t_from: Vec<f64>,
94    /// The target latent coordinate `t_to` (length = atom's `latent_dim`).
95    pub t_to: Vec<f64>,
96    /// The exact amplitude `a` the caller applied to the on-manifold move.
97    pub amplitude: f64,
98    /// The exact row whose output-Fisher metric prices the applied move.
99    pub metric_row: usize,
100    /// **The activation-space delta**: `δ = a · (g_k(t_to) − g_k(t_from))`, a
101    /// length-`p` vector in the reconstruction/output space — the actual move to
102    /// add to a hidden state.
103    pub delta: Array1<f64>,
104    /// **DOSIMETRY**: predicted output effect of the exact applied move in
105    /// **nats** of KL, `0.5 * delta^T M_metric_row delta`.
106    /// `None` when the metric carries no behavioral information (Euclidean
107    /// provenance) — the dose is *not available*, not zero.
108    pub predicted_nats: Option<f64>,
109    /// **VALIDITY RADIUS**: the latent step size (Euclidean norm of the move from
110    /// `t_from`) at which the exact chord dose first diverges from the
111    /// initial-tangent quadratic prediction by more than
112    /// [`VALIDITY_DIVERGENCE_FRACTION`]. Equals the full move length when the
113    /// linearization is trusted all the way to `t_to`. `None` under a no-behavior
114    /// metric (there is no dose to validate).
115    pub validity_radius: Option<f64>,
116    /// **OFF-MANIFOLD GUARD**: the norm of `δ`'s component outside the span of
117    /// the atom's local decoder tangents `∂g_k/∂t` at `t_from`. `≈ 0` by
118    /// construction (the move is a chord of the curve); a large value flags a
119    /// move that left the learned surface.
120    pub off_manifold_norm: f64,
121    /// The provenance of the metric the dose was read through, echoed so a
122    /// consumer can certify *why* `predicted_nats` is `None` when it is.
123    pub metric_provenance: MetricProvenance,
124}
125
126/// Result of writing one certified chart coordinate into an activation row.
127///
128/// The edited row is always `x + δ`, where `δ` is the delta returned by
129/// [`steer_delta`] for the atom's current encoded coordinate and the requested
130/// target coordinate. Because only the on-manifold atom chord is added, every
131/// component of `x` outside this atom's chart residual is preserved exactly; this
132/// is the locality guarantee missing from whole-residual linear-steering
133/// baselines.
134#[derive(Clone, Debug)]
135pub struct CoordinateSetResult {
136    /// The edited activation/reconstruction row.
137    pub edited: Array1<f64>,
138    /// Certified coordinate read from the input row before the write.
139    pub t_from_certified: Array1<f64>,
140    /// Certificate attached to `t_from_certified`.
141    pub encode_certificate: crate::encode::RowCertificate,
142    /// Steering plan whose `delta` was added to the row.
143    pub steer: SteerPlan,
144}
145
146/// Write atom `atom_k`'s chart coordinate in row `x` to `t_to` by delta
147/// steering, preserving the row's off-atom/off-subspace residual exactly.
148///
149/// `amplitude` is the assignment/intensity with which the row expresses this
150/// atom; callers that have already separated existence/intensity/position should
151/// pass the intensity and only swap the position coordinate. The certified read
152/// uses [`EncodeAtlas::certified_encode_row`]; the write uses [`steer_delta`].
153pub fn set_coordinate(
154    model: &SaeManifoldTerm,
155    metric: &RowMetric,
156    atlas: &EncodeAtlas,
157    x: ArrayView1<'_, f64>,
158    atom_k: usize,
159    metric_row: usize,
160    amplitude: f64,
161    t_to: &[f64],
162) -> Result<CoordinateSetResult, String> {
163    let atom = model.atoms.get(atom_k).ok_or_else(|| {
164        format!(
165            "set_coordinate: atom index {atom_k} out of range (term has {} atoms)",
166            model.k_atoms()
167        )
168    })?;
169    if x.len() != atom.output_dim() {
170        return Err(format!(
171            "set_coordinate: input row has length {} but atom {atom_k} output_dim is {}",
172            x.len(),
173            atom.output_dim()
174        ));
175    }
176    let (t_from, cert) = atlas.certified_encode_row(atom, atom_k, x, amplitude)?;
177    let steer = steer_delta(
178        model,
179        metric,
180        atom_k,
181        metric_row,
182        amplitude,
183        t_from.as_slice().unwrap_or(&[]),
184        t_to,
185    )?;
186    let mut edited = x.to_owned();
187    if edited.len() != steer.delta.len() {
188        return Err(format!(
189            "set_coordinate: steering delta length {} does not match row length {}",
190            steer.delta.len(),
191            edited.len()
192        ));
193    }
194    for i in 0..edited.len() {
195        edited[i] += steer.delta[i];
196    }
197    Ok(CoordinateSetResult {
198        edited,
199        t_from_certified: t_from,
200        encode_certificate: cert,
201        steer,
202    })
203}
204
205/// Result of a coordinate interchange: donor position read from `x_source`, then
206/// written into `x_target` while preserving the target residual and intensity.
207#[derive(Clone, Debug)]
208pub struct InterchangeResult {
209    /// Target row after the donor coordinate has been delta-written into it.
210    pub edited_target: Array1<f64>,
211    /// Donor/source coordinate that was transplanted.
212    pub donor_t: Array1<f64>,
213    /// Target coordinate before the transplant.
214    pub target_t_before: Array1<f64>,
215    /// Target behavior coordinate after the transplant, re-read from the edit.
216    pub target_t_after: Array1<f64>,
217    /// Steering dose in nats, when a behavioral metric is available.
218    pub predicted_nats: Option<f64>,
219    /// Norm of the steering delta outside the local atom tangent frame.
220    pub off_manifold_norm: f64,
221    /// Reported steering validity radius.
222    pub validity_radius: Option<f64>,
223    /// Geodesic chart-coordinate landing error. Wrapped axes use their shortest
224    /// signed displacement. This is a descriptive reconstruction diagnostic,
225    /// not a p/e-value: no counterfactual null distribution is available here.
226    pub landing_error: 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 landing error
236/// is descriptive; statistical evidence requires an externally specified null
237/// experiment and is deliberately not fabricated from the error magnitude.
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    target_metric_row: usize,
248) -> Result<InterchangeResult, String> {
249    let atom = model.atoms.get(atom_k).ok_or_else(|| {
250        format!(
251            "interchange: atom index {atom_k} out of range (term has {} atoms)",
252            model.k_atoms()
253        )
254    })?;
255    let (donor_t, _donor_cert) =
256        atlas.certified_encode_row(atom, atom_k, x_source, source_amplitude)?;
257    let set = set_coordinate(
258        model,
259        metric,
260        atlas,
261        x_target,
262        atom_k,
263        target_metric_row,
264        target_amplitude,
265        donor_t.as_slice().unwrap_or(&[]),
266    )?;
267    let (target_t_after, _after_cert) =
268        atlas.certified_encode_row(atom, atom_k, set.edited.view(), target_amplitude)?;
269    let periods = model.assignment.coords[atom_k].effective_axis_periods();
270    let landing_error = coordinate_l2_distance(
271        donor_t.as_slice().unwrap_or(&[]),
272        target_t_after.as_slice().unwrap_or(&[]),
273        &periods,
274    )?;
275    Ok(InterchangeResult {
276        edited_target: set.edited.clone(),
277        donor_t,
278        target_t_before: set.t_from_certified.clone(),
279        target_t_after,
280        predicted_nats: set.steer.predicted_nats,
281        off_manifold_norm: set.steer.off_manifold_norm,
282        validity_radius: set.steer.validity_radius,
283        landing_error,
284        set_result: set,
285    })
286}
287
288fn shortest_coordinate_delta(
289    from: &[f64],
290    to: &[f64],
291    periods: &[Option<f64>],
292) -> Result<Vec<f64>, String> {
293    if from.len() != to.len() || from.len() != periods.len() {
294        return Err(format!(
295            "coordinate displacement length mismatch: from={}, to={}, periods={}",
296            from.len(),
297            to.len(),
298            periods.len()
299        ));
300    }
301    let mut delta = Vec::with_capacity(from.len());
302    for axis in 0..from.len() {
303        let mut d = to[axis] - from[axis];
304        if let Some(period) = periods[axis] {
305            if !(period.is_finite() && period > 0.0) {
306                return Err(format!(
307                    "coordinate axis {axis} has invalid period {period}"
308                ));
309            }
310            d -= period * (d / period).round();
311        }
312        delta.push(d);
313    }
314    Ok(delta)
315}
316
317fn coordinate_l2_distance(a: &[f64], b: &[f64], periods: &[Option<f64>]) -> Result<f64, String> {
318    Ok(shortest_coordinate_delta(a, b, periods)?
319        .iter()
320        .map(|d| d * d)
321        .sum::<f64>()
322        .sqrt())
323}
324
325fn path_coordinate(
326    from: &[f64],
327    delta: &[f64],
328    periods: &[Option<f64>],
329    fraction: f64,
330) -> Vec<f64> {
331    from.iter()
332        .zip(delta.iter())
333        .zip(periods.iter())
334        .map(|((&start, &step), &period)| {
335            let value = start + fraction * step;
336            period.map_or(value, |p| value.rem_euclid(p))
337        })
338        .collect()
339}
340
341/// Build a [`SteerPlan`] for driving atom `atom_k` from `t_from` to `t_to`.
342///
343/// `model` is the fitted term (read only); `metric` is the per-row output-Fisher
344/// inner product the dose is measured through (typically `model.row_metric()`'s
345/// own metric, or any metric whose row/output dims match the term). `t_from` and
346/// `t_to` are latent coordinates of length `atom.latent_dim`.
347///
348/// Errors when the atom index is out of range, the coordinate lengths do not
349/// match the atom's latent dimension, the atom has no installed
350/// [`crate::manifold::SaeBasisEvaluator`] (arbitrary-`t` evaluation
351/// requires one), or the metric dimensions do not match the term. Under a
352/// Euclidean (no-behavior) metric the geometry is still produced but
353/// `predicted_nats` / `validity_radius` degrade to `None`.
354pub fn steer_delta(
355    model: &SaeManifoldTerm,
356    metric: &RowMetric,
357    atom_k: usize,
358    metric_row: usize,
359    amplitude: f64,
360    t_from: &[f64],
361    t_to: &[f64],
362) -> Result<SteerPlan, String> {
363    if !(amplitude.is_finite() && amplitude > 0.0) {
364        return Err(format!(
365            "steer_delta: amplitude must be finite and positive, got {amplitude}"
366        ));
367    }
368    let k = model.k_atoms();
369    if atom_k >= k {
370        return Err(format!(
371            "steer_delta: atom index {atom_k} out of range (term has {k} atoms)"
372        ));
373    }
374    let atom = &model.atoms[atom_k];
375    let d = atom.latent_dim;
376    let p = atom.output_dim();
377    if t_from.len() != d || t_to.len() != d {
378        return Err(format!(
379            "steer_delta: t_from/t_to must have length latent_dim={d}; got {} and {}",
380            t_from.len(),
381            t_to.len()
382        ));
383    }
384    atom.basis_evaluator.as_ref().ok_or_else(|| {
385        format!(
386            "steer_delta: atom {atom_k} ('{}') has no installed basis evaluator; \
387             arbitrary-t decoder evaluation requires one",
388            atom.name
389        )
390    })?;
391    let periods = model.assignment.coords[atom_k].effective_axis_periods();
392    let coordinate_delta = shortest_coordinate_delta(t_from, t_to, &periods)?;
393
394    let n = model.n_obs();
395    if metric.n_rows() != n || metric.p_out() != p {
396        return Err(format!(
397            "steer_delta: metric shape ({}, {}) must equal fitted term shape ({n}, {p})",
398            metric.n_rows(),
399            metric.p_out()
400        ));
401    }
402    if metric_row >= n {
403        return Err(format!(
404            "steer_delta: metric_row={metric_row} out of range for {n} fitted rows"
405        ));
406    }
407
408    // --- the on-manifold activation-space delta -----------------------------
409    let g_from = decode_at(atom, t_from)?;
410    let g_to = decode_at(atom, t_to)?;
411    let mut delta = Array1::<f64>::zeros(p);
412    for i in 0..p {
413        delta[i] = amplitude * (g_to[i] - g_from[i]);
414    }
415
416    // Whether the metric can/does match this term and carries behavior.
417    let provenance = metric.provenance();
418    let behavior_available = metric_carries_behavior(provenance);
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] = t_from[a] + 0.5 * coordinate_delta[a];
432        if let Some(period) = periods[a] {
433            t_mid[a] = t_mid[a].rem_euclid(period);
434        }
435    }
436    let tangents = decode_tangents_at(atom, &t_mid)?;
437    let off_manifold_norm = off_manifold_residual_norm(&tangents, delta.view());
438
439    // --- dosimetry: exact applied-delta Fisher endpoint KL ------------------
440    let (predicted_nats, validity_radius) = if !behavior_available {
441        (None, None)
442    } else {
443        let ctx = SteerContext {
444            atom,
445            metric,
446            row: metric_row,
447            p,
448            d,
449            amplitude,
450            coordinate_delta: &coordinate_delta,
451            periods: &periods,
452        };
453        let dose = 0.5 * metric.fisher_mass(metric_row, delta.view());
454        let radius = validity_radius(&ctx, t_from)?;
455        (Some(dose), Some(radius))
456    };
457
458    Ok(SteerPlan {
459        atom: atom_k,
460        atom_name: atom.name.clone(),
461        t_from: t_from.to_vec(),
462        t_to: t_to.to_vec(),
463        amplitude,
464        metric_row,
465        delta,
466        predicted_nats,
467        validity_radius,
468        off_manifold_norm,
469        metric_provenance: provenance,
470    })
471}
472
473/// The model's predicted output-mean response to an applied activation push
474/// `δ`, under the LOCAL-LINEAR reading of its fitted surface: the projection
475/// of `δ` onto the span of atom `atom_k`'s decoder tangents `∂g_k/∂t` at the
476/// operating point `t_at`. A dictionary "predicts" exactly the component of a
477/// push it can carry along its learned surface; the transverse component is
478/// off-manifold and predicted to die (this is the same local model the
479/// off-manifold guard and the dosimetry chord trust, used in the same radius).
480///
481/// This is `μ(δ)` for the design loop of
482/// [`gam_terms::inference::structure_evidence`]: two structural hypotheses about
483/// the same activations (e.g. "one curved atom" vs "two flat atoms") are two
484/// fitted terms whose tangent spans differ, so they predict DIFFERENT
485/// responses to the same probe — and that disagreement, in the output-Fisher
486/// metric, is what `select_probe_by_expected_evidence` maximizes.
487pub fn predicted_response(
488    model: &SaeManifoldTerm,
489    atom_k: usize,
490    t_at: &[f64],
491    delta: ArrayView1<'_, f64>,
492) -> Result<Array1<f64>, String> {
493    let k = model.k_atoms();
494    if atom_k >= k {
495        return Err(format!(
496            "predicted_response: atom index {atom_k} out of range (term has {k} atoms)"
497        ));
498    }
499    let atom = &model.atoms[atom_k];
500    let d = atom.latent_dim;
501    let p = atom.output_dim();
502    if t_at.len() != d {
503        return Err(format!(
504            "predicted_response: t_at must have length latent_dim={d}; got {}",
505            t_at.len()
506        ));
507    }
508    if delta.len() != p {
509        return Err(format!(
510            "predicted_response: delta must have length output_dim={p}; got {}",
511            delta.len()
512        ));
513    }
514    atom.basis_evaluator.as_ref().ok_or_else(|| {
515        format!(
516            "predicted_response: atom {atom_k} ('{}') has no installed basis evaluator",
517            atom.name
518        )
519    })?;
520    let tangents = decode_tangents_at(atom, t_at)?;
521    Ok(project_onto_tangent_span(&tangents, delta))
522}
523
524/// Does this provenance carry behavioral (output-Fisher) information? Euclidean
525/// is the isotropic activation-only path and carries none; the factored
526/// provenances do. (Mirrors `atom_lens::metric_carries_behavior`.)
527fn metric_carries_behavior(p: MetricProvenance) -> bool {
528    match p {
529        MetricProvenance::Euclidean | MetricProvenance::WhitenedStructured { .. } => false,
530        MetricProvenance::OutputFisher { .. }
531        | MetricProvenance::OutputFisherDownstream { .. }
532        | MetricProvenance::BehavioralFisher { .. } => true,
533    }
534}
535
536/// Evaluate the decoder output `g_k(t) = Φ_k(t) B_k ∈ ℝ^p` at an arbitrary
537/// latent coordinate `t` (length `d`) via the atom's installed evaluator.
538fn decode_at(atom: &SaeManifoldAtom, t: &[f64]) -> Result<Array1<f64>, String> {
539    let d = t.len();
540    let coords = Array2::from_shape_vec((1, d), t.to_vec())
541        .map_err(|e| format!("steer_delta::decode_at: coord shape: {e}"))?;
542    Ok(atom.decode_at_coords(coords.view())?.row(0).to_owned())
543}
544
545/// Evaluate the decoder tangents `∂g_k/∂t_a = Φ_k'(t) B_k ∈ ℝ^p`, one per latent
546/// axis `a ∈ 0..d`, at an arbitrary latent coordinate `t`. Returned as a
547/// `(p × d)` matrix whose column `a` is the tangent along axis `a`.
548fn decode_tangents_at(atom: &SaeManifoldAtom, t: &[f64]) -> Result<Array2<f64>, String> {
549    let evaluator = atom.basis_evaluator.as_ref().ok_or_else(|| {
550        "steer_delta::decode_tangents_at: atom has no installed basis evaluator".to_string()
551    })?;
552    let p = atom.output_dim();
553    let d = atom.latent_dim;
554    let coords = Array2::from_shape_vec((1, d), t.to_vec())
555        .map_err(|e| format!("steer_delta::decode_tangents_at: coord shape: {e}"))?;
556    let jet = if atom.homotopy_eta == 1.0 {
557        evaluator.evaluate(coords.view())?.1
558    } else {
559        evaluator
560            .evaluate_phi_eta(coords.view(), atom.homotopy_eta)?
561            .jet
562    };
563    let decoder = &atom.decoder_coefficients;
564    let m = decoder.nrows();
565    if jet.dim() != (1, m, d) {
566        return Err(format!(
567            "steer_delta::decode_tangents_at: evaluator jet {:?} != (1, {m}, {d})",
568            jet.dim()
569        ));
570    }
571    let mut tang = Array2::<f64>::zeros((p, d));
572    for axis in 0..d {
573        for basis_col in 0..m {
574            let dphi = jet[[0, basis_col, axis]];
575            if dphi == 0.0 {
576                continue;
577            }
578            for out_col in 0..p {
579                tang[[out_col, axis]] += dphi * decoder[[basis_col, out_col]];
580            }
581        }
582    }
583    Ok(tang)
584}
585
586/// Least-squares projection of `δ` onto the span of the local tangents
587/// (columns of `tangents`, shape `p × d`): `δ̂ = T (TᵀT)⁻¹ Tᵀ δ` via a small
588/// `d × d` Gram solve (with a tiny diagonal jitter to absorb a rank-deficient
589/// tangent frame; the jitter only shrinks the projection, never inflates it).
590fn project_onto_tangent_span(tangents: &Array2<f64>, delta: ArrayView1<'_, f64>) -> Array1<f64> {
591    let p = tangents.nrows();
592    let d = tangents.ncols();
593    if d == 0 {
594        return Array1::<f64>::zeros(p);
595    }
596    // Gram = TᵀT (d × d) and rhs = Tᵀδ (d).
597    let mut gram = Array2::<f64>::zeros((d, d));
598    let mut rhs = Array1::<f64>::zeros(d);
599    for a in 0..d {
600        let mut r = 0.0_f64;
601        for i in 0..p {
602            r += tangents[[i, a]] * delta[i];
603        }
604        rhs[a] = r;
605        for b in a..d {
606            let mut acc = 0.0_f64;
607            for i in 0..p {
608                acc += tangents[[i, a]] * tangents[[i, b]];
609            }
610            gram[[a, b]] = acc;
611            gram[[b, a]] = acc;
612        }
613    }
614    let trace: f64 = (0..d).map(|a| gram[[a, a]]).sum();
615    let jitter = if trace > 0.0 { 1e-12 * trace } else { 1e-12 };
616    for a in 0..d {
617        gram[[a, a]] += jitter;
618    }
619    let coeffs = solve_spd_small(&gram, &rhs);
620    let mut proj = Array1::<f64>::zeros(p);
621    for i in 0..p {
622        for a in 0..d {
623            proj[i] += tangents[[i, a]] * coeffs[a];
624        }
625    }
626    proj
627}
628
629/// Norm of `δ`'s component orthogonal to the span of the local tangents:
630/// `‖δ − δ̂‖` with `δ̂` the [`project_onto_tangent_span`] projection.
631fn off_manifold_residual_norm(tangents: &Array2<f64>, delta: ArrayView1<'_, f64>) -> f64 {
632    let proj = project_onto_tangent_span(tangents, delta);
633    let mut res_sq = 0.0_f64;
634    for i in 0..delta.len() {
635        let r = delta[i] - proj[i];
636        res_sq += r * r;
637    }
638    res_sq.max(0.0).sqrt()
639}
640
641/// Tiny symmetric-positive-definite solve via Cholesky for the `d × d` tangent
642/// Gram (`d` is the atom's latent dim, typically 1–3). Falls back to the bare rhs
643/// if the factorization fails (a fully degenerate frame), which only inflates the
644/// reported off-manifold residual — never deflates it.
645fn solve_spd_small(gram: &Array2<f64>, rhs: &Array1<f64>) -> Array1<f64> {
646    let d = gram.nrows();
647    // Cholesky L LᵀT = gram.
648    let mut l = Array2::<f64>::zeros((d, d));
649    for i in 0..d {
650        for j in 0..=i {
651            let mut sum = gram[[i, j]];
652            for k in 0..j {
653                sum -= l[[i, k]] * l[[j, k]];
654            }
655            if i == j {
656                if sum <= 0.0 {
657                    return Array1::<f64>::zeros(d);
658                }
659                l[[i, j]] = sum.sqrt();
660            } else {
661                l[[i, j]] = sum / l[[j, j]];
662            }
663        }
664    }
665    // Forward solve L y = rhs.
666    let mut y = Array1::<f64>::zeros(d);
667    for i in 0..d {
668        let mut sum = rhs[i];
669        for k in 0..i {
670            sum -= l[[i, k]] * y[k];
671        }
672        y[i] = sum / l[[i, i]];
673    }
674    // Back solve Lᵀ x = y.
675    let mut x = Array1::<f64>::zeros(d);
676    for i in (0..d).rev() {
677        let mut sum = y[i];
678        for k in (i + 1)..d {
679            sum -= l[[k, i]] * x[k];
680        }
681        x[i] = sum / l[[i, i]];
682    }
683    x
684}
685
686/// The fixed geometry of one steering query, bundled so the dose integrator and
687/// its helpers take a single context rather than a long argument list.
688struct SteerContext<'a> {
689    atom: &'a SaeManifoldAtom,
690    metric: &'a RowMetric,
691    /// The row whose per-row metric the dose is measured through.
692    row: usize,
693    /// Output dimension `p`.
694    p: usize,
695    /// Latent dimension `d`.
696    d: usize,
697    /// Amplitude `a` the move is scaled by.
698    amplitude: f64,
699    coordinate_delta: &'a [f64],
700    periods: &'a [Option<f64>],
701}
702
703/// The validity radius: the latent step length (Euclidean distance from
704/// `t_from`) at which **local linearization stops being trusted**.
705///
706/// Linearizing the steering move means predicting the output effect of a prefix
707/// step `τ·Δt` from the initial tangent alone: the first-order output move is
708/// `δ_lin(τ) = a · (∂g/∂t|_{t_from}) · (τ Δt)`, whose output-Fisher KL is the
709/// quadratic form `½ ‖δ_lin(τ)‖²_M = τ² · ½ a² ‖∂g/∂t·Δt‖²_M`. The **true**
710/// effect of that prefix is the chord quadratic form of the *actual* curved
711/// output move `½ a² ‖g(t_from + τΔt) − g(t_from)‖²_M`.
712///
713/// The radius is the chord length `τ* · ‖Δt‖` at the first prefix `τ*` where the
714/// true chord KL diverges from the linear prediction by more than
715/// [`VALIDITY_DIVERGENCE_FRACTION`] (relative to the linear prediction). This is
716/// pure surface curvature: on a flat decoder the two agree for every `τ` and the
717/// radius is the whole move. If the metric kills the tangent (no linear effect to
718/// validate), the move is trusted to its full length.
719fn validity_radius(ctx: &SteerContext<'_>, t_from: &[f64]) -> Result<f64, String> {
720    let d = ctx.d;
721    let p = ctx.p;
722    let full_len: f64 = ctx
723        .coordinate_delta
724        .iter()
725        .map(|d| d * d)
726        .sum::<f64>()
727        .sqrt();
728    if full_len == 0.0 {
729        return Ok(0.0);
730    }
731    let dt = ctx.coordinate_delta;
732    let amp = ctx.amplitude;
733
734    // Initial-tangent linear output move per unit τ: v0 = (∂g/∂t|_{t_from}) Δt.
735    let tang0 = decode_tangents_at(ctx.atom, t_from)?;
736    let mut v0 = Array1::<f64>::zeros(p);
737    for i in 0..p {
738        let mut acc = 0.0_f64;
739        for a in 0..d {
740            acc += tang0[[i, a]] * dt[a];
741        }
742        v0[i] = acc;
743    }
744    // ½ a² ‖v0‖²_M — the per-τ² linear KL coefficient.
745    let lin_coeff = 0.5 * amp * amp * ctx.metric.fisher_mass(ctx.row, v0.view());
746    // No linear effect to validate against ⇒ trust the full move.
747    if !(lin_coeff > 0.0) {
748        return Ok(full_len);
749    }
750
751    let g_from = decode_at(ctx.atom, t_from)?;
752    let steps = STEER_VALIDITY_STEPS;
753    for s in 0..steps {
754        let tau = (s as f64 + 1.0) / steps as f64;
755        let t_mid = path_coordinate(t_from, dt, ctx.periods, tau);
756        let g_tau = decode_at(ctx.atom, &t_mid)?;
757        let mut chord = Array1::<f64>::zeros(p);
758        for i in 0..p {
759            chord[i] = amp * (g_tau[i] - g_from[i]);
760        }
761        // True chord KL of the prefix, and the linear prediction τ²·lin_coeff.
762        let chord_kl = 0.5 * ctx.metric.fisher_mass(ctx.row, chord.view());
763        let lin_kl = tau * tau * lin_coeff;
764        let rel = (chord_kl - lin_kl).abs() / lin_kl;
765        if rel > VALIDITY_DIVERGENCE_FRACTION {
766            return Ok(tau * full_len);
767        }
768    }
769    Ok(full_len)
770}
771
772/// One dose sample on a collateral-damage curve (gam#2234 E2, the intrinsic
773/// Rust-owned counterpart of the model-in-the-loop KL frontier): the on-target
774/// effect and the off-target collateral of a single steering intervention,
775/// measured in the fitted dictionary's own representation, with no LLM in the
776/// loop.
777#[derive(Clone, Debug, PartialEq, serde::Serialize)]
778pub struct CollateralPoint {
779    /// The chart-coordinate dose applied to the target atom's steered axis
780    /// (radians / fraction-of-period, per the atom's manifold).
781    pub dose: f64,
782    /// RMS-over-rows on-target effect: `‖proj_{T_k} Δ‖`, the energy the
783    /// intervention deposits into the TARGET atom's own local decode-tangent
784    /// frame `T_k = ∂g_k/∂t` at each row's fitted operating point. This is the
785    /// intended landing — how loudly the knob turned the feature it names.
786    pub on_target_effect: f64,
787    /// RMS-over-rows collateral: `‖Δ − proj_{T_k} Δ‖`, the energy the SAME
788    /// intervention deposits OUTSIDE the target atom's own local frame — the total
789    /// damage. The on-manifold move is a chord of atom `k`'s decoder curve, so its
790    /// off-target component is only the second-order sagitta (`≈ 0`, growing with
791    /// dose-curvature); a fixed flat direction is off the rotating target frame at
792    /// most rows, so its off-target energy is immediate. This is the direct
793    /// generalization of the single-move [`SteerPlan::off_manifold_norm`] guard to
794    /// a swept intervention.
795    pub collateral: f64,
796    /// RMS-over-rows CROSS-FEATURE leakage: `sqrt(Σ_{j∈others} ‖proj_{T_j} Δ‖²)`,
797    /// the part of the move that lands on OTHER named atoms' frames — the
798    /// interpretable "steering feature `k` spuriously moved feature `j`" damage, a
799    /// component of the total `collateral`.
800    pub cross_feature: f64,
801}
802
803/// One intervention family's swept collateral curve plus its aggregate
804/// collateral efficiency (collateral energy spent per unit on-target effect).
805#[derive(Clone, Debug, PartialEq, serde::Serialize)]
806pub struct CollateralArm {
807    /// Per-dose `(effect, collateral)` samples, in the order of the input doses.
808    pub points: Vec<CollateralPoint>,
809    /// Collateral energy per unit on-target effect over the swept doses:
810    /// `sqrt(Σ collateral²) / sqrt(Σ effect²)`. Lower is a cleaner control knob.
811    /// `NaN` when the arm achieves no on-target effect at any dose.
812    pub efficiency: f64,
813}
814
815/// The on-manifold-vs-flat collateral-damage comparison for one target atom
816/// (gam#2234 E2 thesis, measured intrinsically — no model surgery, no outer-fit
817/// convergence in the loop). The two arms move the SAME per-row ambient energy:
818/// the flat arm applies it along a single fixed decoder direction (the flat-SAE
819/// `x' = x + α·w` baseline), the manifold arm applies the chart-coordinate group
820/// action `x' = x + a·(Φ_k(t⊕δ) − Φ_k(t))·B_k`, which rotates with each row's
821/// coordinate to stay on the atom's decoded image. The thesis: at matched
822/// per-row norm the manifold arm spends strictly less collateral per unit
823/// on-target effect — curved features are the right control knobs.
824#[derive(Clone, Debug, PartialEq, serde::Serialize)]
825pub struct CollateralCurve {
826    /// The steered (target) atom.
827    pub atom: usize,
828    /// The target atom's latent axis the dose is applied along.
829    pub axis: usize,
830    /// The atoms collateral is measured against (typically every `j ≠ atom`).
831    pub others: Vec<usize>,
832    /// The on-manifold group-action arm.
833    pub manifold: CollateralArm,
834    /// The matched-per-row-norm fixed-direction (flat-SAE) control arm.
835    pub flat: CollateralArm,
836    /// `true` when the on-manifold arm spends strictly less collateral per unit
837    /// on-target effect than the flat arm (`manifold.efficiency < flat.efficiency`),
838    /// with both efficiencies finite — the E2 dominance verdict, decided
839    /// structurally in the SAE's own representation.
840    pub manifold_is_cleaner: bool,
841}
842
843/// Norm of `δ`'s component that lands inside the span of a local decode-tangent
844/// frame — the energy the ambient move deposits into that atom's feature
845/// direction at its current operating point.
846fn frame_landed_norm(frame: &Array2<f64>, delta: ArrayView1<'_, f64>) -> f64 {
847    let proj = project_onto_tangent_span(frame, delta);
848    proj.iter().map(|&x| x * x).sum::<f64>().sqrt()
849}
850
851/// Sweep the intrinsic collateral-damage curve for steering atom `atom_k` along
852/// latent `axis` over `doses`, comparing the on-manifold group action against a
853/// matched-per-row-norm flat-direction control (gam#2234 E2).
854///
855/// For each dose `δ` the on-manifold ambient move is the fitted group-action
856/// delta [`SaeManifoldTerm::steer_rows`] (chart step `δ` on `axis`, gate held
857/// fixed). The flat control replays the SAME per-row move NORM along one fixed
858/// ambient direction `w` — the atom's mean decode-tangent direction along `axis`,
859/// the manifold analog of a flat SAE's single decoder column. Each move is
860/// decomposed against every atom's local decode-tangent frame at its fitted
861/// coordinate: the projection onto the TARGET atom's frame is the on-target
862/// effect, the projection onto the OTHER atoms' frames is the collateral.
863///
864/// This is a pure read over the fitted term (no criterion, no penalty, no outer
865/// fit), so it runs on any fitted or hand-built term with installed evaluators —
866/// it does not wait on outer-loop convergence, unlike the model-in-the-loop E1/E2
867/// KL frontier it mirrors.
868///
869/// Errors when `atom_k`/`axis`/an `others` index is out of range, `doses` is
870/// empty, or the target atom's mean tangent along `axis` vanishes (no fixed
871/// direction to define the flat control against).
872pub fn collateral_curve(
873    model: &SaeManifoldTerm,
874    atom_k: usize,
875    axis: usize,
876    others: &[usize],
877    doses: &[f64],
878) -> Result<CollateralCurve, String> {
879    let k = model.k_atoms();
880    if atom_k >= k {
881        return Err(format!(
882            "collateral_curve: atom index {atom_k} out of range (term has {k} atoms)"
883        ));
884    }
885    let d_k = model.atoms[atom_k].latent_dim;
886    if axis >= d_k {
887        return Err(format!(
888            "collateral_curve: axis {axis} out of range for atom {atom_k} latent_dim {d_k}"
889        ));
890    }
891    if doses.is_empty() {
892        return Err("collateral_curve: doses must be non-empty".to_string());
893    }
894    for &j in others {
895        if j >= k {
896            return Err(format!(
897                "collateral_curve: other atom index {j} out of range (term has {k} atoms)"
898            ));
899        }
900    }
901    let n = model.n_obs();
902    let p = model.output_dim();
903    let rows: Vec<usize> = (0..n).collect();
904
905    // Per-row local decode-tangent frames at each atom's fitted operating point.
906    // These are dose-independent (the fitted coordinates never move; steering is
907    // the hypothetical move whose leakage we price against the CURRENT features).
908    let frame_at = |atom_idx: usize| -> Result<Vec<Array2<f64>>, String> {
909        let coords = model.assignment.coords[atom_idx].as_matrix();
910        let mut frames = Vec::with_capacity(n);
911        for row in 0..n {
912            let t: Vec<f64> = coords.row(row).to_vec();
913            frames.push(decode_tangents_at(&model.atoms[atom_idx], &t)?);
914        }
915        Ok(frames)
916    };
917    let target_frames = frame_at(atom_k)?;
918    let mut other_frames: Vec<Vec<Array2<f64>>> = Vec::with_capacity(others.len());
919    for &j in others {
920        other_frames.push(frame_at(j)?);
921    }
922
923    // The fixed flat direction w: the dominant ambient direction the target atom
924    // moves along `axis` — the top left singular vector of its per-row tangent
925    // field, i.e. the leading eigenvector of `G = Σ_i g_i g_iᵀ` with
926    // `g_i = ∂g_k/∂t_axis|_{t_i}`. This is the single best fixed decoder column a
927    // flat SAE would steer this feature with (the mean tangent is not usable — it
928    // averages to ≈0 over a full circle). Found by power iteration on the small
929    // `p × p` Gram, which is exact for the leading direction.
930    let mut gram = Array2::<f64>::zeros((p, p));
931    for frame in &target_frames {
932        for i in 0..p {
933            let gi = frame[[i, axis]];
934            if gi == 0.0 {
935                continue;
936            }
937            for j in 0..p {
938                gram[[i, j]] += gi * frame[[j, axis]];
939            }
940        }
941    }
942    let mut w = Array1::<f64>::from_elem(p, 1.0 / (p as f64).sqrt());
943    for _ in 0..128 {
944        let mut next = Array1::<f64>::zeros(p);
945        for i in 0..p {
946            let mut acc = 0.0_f64;
947            for j in 0..p {
948                acc += gram[[i, j]] * w[j];
949            }
950            next[i] = acc;
951        }
952        let norm = next.iter().map(|&x| x * x).sum::<f64>().sqrt();
953        if !(norm > 0.0) {
954            return Err(format!(
955                "collateral_curve: atom {atom_k} has a vanishing tangent field along axis {axis}; \
956                 no fixed direction to define the flat control"
957            ));
958        }
959        next.mapv_inplace(|x| x / norm);
960        w = next;
961    }
962
963    // Decompose one per-row move field into (effect, off-target collateral,
964    // cross-feature leakage) RMS over rows.
965    let decompose = |field: &Array2<f64>| -> CollateralPoint {
966        let mut eff_sq = 0.0_f64;
967        let mut col_sq = 0.0_f64;
968        let mut cross_sq = 0.0_f64;
969        for row in 0..n {
970            let delta = field.row(row);
971            let on_target = project_onto_tangent_span(&target_frames[row], delta);
972            let mut e = 0.0_f64;
973            let mut c = 0.0_f64;
974            for i in 0..p {
975                e += on_target[i] * on_target[i];
976                let residual = delta[i] - on_target[i];
977                c += residual * residual;
978            }
979            eff_sq += e;
980            col_sq += c;
981            let mut cross = 0.0_f64;
982            for frames in &other_frames {
983                let l = frame_landed_norm(&frames[row], delta);
984                cross += l * l;
985            }
986            cross_sq += cross;
987        }
988        let denom = n.max(1) as f64;
989        CollateralPoint {
990            dose: 0.0,
991            on_target_effect: (eff_sq / denom).sqrt(),
992            collateral: (col_sq / denom).sqrt(),
993            cross_feature: (cross_sq / denom).sqrt(),
994        }
995    };
996
997    let mut manifold_pts = Vec::with_capacity(doses.len());
998    let mut flat_pts = Vec::with_capacity(doses.len());
999    for &dose in doses {
1000        let mut step = Array1::<f64>::zeros(d_k);
1001        step[axis] = dose;
1002        let on_field = model.steer_rows(atom_k, &rows, step.view())?;
1003
1004        // Matched control: same per-row move NORM, along the fixed direction w.
1005        let mut flat_field = Array2::<f64>::zeros((n, p));
1006        for row in 0..n {
1007            let norm = on_field.row(row).iter().map(|&x| x * x).sum::<f64>().sqrt();
1008            for i in 0..p {
1009                flat_field[[row, i]] = norm * w[i];
1010            }
1011        }
1012
1013        let mut m = decompose(&on_field);
1014        m.dose = dose;
1015        manifold_pts.push(m);
1016        let mut f = decompose(&flat_field);
1017        f.dose = dose;
1018        flat_pts.push(f);
1019    }
1020
1021    let efficiency = |pts: &[CollateralPoint]| -> f64 {
1022        let eff_sq: f64 = pts
1023            .iter()
1024            .map(|q| q.on_target_effect * q.on_target_effect)
1025            .sum();
1026        let col_sq: f64 = pts.iter().map(|q| q.collateral * q.collateral).sum();
1027        if eff_sq > 0.0 {
1028            (col_sq / eff_sq).sqrt()
1029        } else {
1030            f64::NAN
1031        }
1032    };
1033    let manifold = CollateralArm {
1034        efficiency: efficiency(&manifold_pts),
1035        points: manifold_pts,
1036    };
1037    let flat = CollateralArm {
1038        efficiency: efficiency(&flat_pts),
1039        points: flat_pts,
1040    };
1041    let manifold_is_cleaner = manifold.efficiency.is_finite()
1042        && flat.efficiency.is_finite()
1043        && manifold.efficiency < flat.efficiency;
1044
1045    Ok(CollateralCurve {
1046        atom: atom_k,
1047        axis,
1048        others: others.to_vec(),
1049        manifold,
1050        flat,
1051        manifold_is_cleaner,
1052    })
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057    use super::*;
1058
1059    #[test]
1060    fn periodic_steering_uses_shortest_path_across_seam() {
1061        let periods = [Some(1.0)];
1062        let delta = shortest_coordinate_delta(&[0.99], &[0.01], &periods).unwrap();
1063        assert!((delta[0] - 0.02).abs() < 1e-12);
1064        let midpoint = path_coordinate(&[0.99], &delta, &periods, 0.5);
1065        assert!(midpoint[0].abs() < 1e-12 || (midpoint[0] - 1.0).abs() < 1e-12);
1066        let distance = coordinate_l2_distance(&[0.99], &[0.01], &periods).unwrap();
1067        assert!((distance - 0.02).abs() < 1e-12);
1068    }
1069}