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}