gam_sae/inference/atom_lens.rs
1//! Two-score per-atom **lens** (#980, amended): an *additive* per-atom report on
2//! a fitted `SaeManifoldTerm`.
3//!
4//! # The amendment this file encodes
5//!
6//! The original #980 framing folded the output-Fisher metric into the SAE
7//! *loss* — "replace the Euclidean reconstruction loss by a Fisher-pulled-back
8//! loss". That is wrong: it makes the gauge drive the fit, which silently
9//! suppresses any structure that is *represented but not currently used*, and it
10//! couples the criterion to a quantity (the output-Fisher factors) that is
11//! optional and may be absent. The corrected paradigm:
12//!
13//! * **The SAE fit stays on activations.** The reconstruction likelihood
14//! whitens through the `RowMetric`
15//! exactly as before; with the default Euclidean provenance that is the
16//! bit-for-bit isotropic path. The Fisher metric **never** replaces the loss.
17//! * **The lens is an additive report.** It reads the *already-fitted* model and
18//! the (optional) `RowMetric`, and emits, per atom, two orthogonal scores plus
19//! their discrepancy. Nothing it computes feeds back into any loss, criterion,
20//! penalty, or optimizer state.
21//!
22//! # The two scores
23//!
24//! For each atom `k`:
25//!
26//! * **presence** (representational, activation-side, *Fisher-free*): how
27//! strongly the atom is encoded *in the activations*. The support-weighted
28//! mean assignment mass `Σw²/Σw`, times an amplitude-weighted decoder norm.
29//! This is a pure reconstruction-side quantity: it does not touch the
30//! `RowMetric` at all, so it is identical whether or not output-Fisher factors
31//! were supplied. *Everything represented survives* — a loud-but-inert atom is
32//! just as present as a quiet load-bearing one.
33//! * **coupling** (behavioral, *the only place Fisher enters*): the output-Fisher
34//! mass along the atom's decoder tangent `dg_k/dt`, averaged over the same
35//! support measure. This is computed through
36//! [`RowMetric::fisher_mass`](gam_problem::RowMetric::fisher_mass)
37//! — a *reported* score, never folded into a loss or criterion. Under a
38//! Euclidean / no-Fisher provenance the coupling is **not available** (`None`),
39//! degrading gracefully exactly as the harvest of the Fisher factors is
40//! optional. It is never an error.
41//!
42//! # The headline: discrepancy
43//!
44//! `discrepancy = normalized_presence − normalized_coupling`. A high value means
45//! **high presence + low coupling**: the atom is strongly *represented* in the
46//! activations yet carries almost no behavioral mass — "represented but not
47//! currently used", i.e. *thinking it, not saying it*. That is the headline
48//! safety number this lens exists to surface. The lens *reports* it; it does not
49//! suppress the atom, because suppression would be the loss-replacement mistake
50//! the amendment removes.
51
52use ndarray::{ArrayView1, ArrayView2};
53
54use crate::manifold::{SaeManifoldTerm, SupportMeasure};
55use gam_problem::{MetricProvenance, RowMetric};
56
57/// Legacy active-row floor used by geometry-only summaries and reseed heuristics
58/// that still report a count of materially active rows. Behavior lens scores
59/// consume [`SupportMeasure`] directly and do not threshold support.
60pub const SAE_TRUST_ACTIVE_MASS_FLOOR: f64 = 1e-6;
61
62/// One atom's lens entry.
63#[derive(Clone, Debug, PartialEq)]
64pub struct AtomLensEntry {
65 /// The atom's name (mirrors [`crate::manifold::SaeManifoldAtom::name`]).
66 pub name: String,
67 /// **presence** (representational, activation-side, Fisher-free): support-
68 /// weighted mean assignment mass × amplitude-weighted decoder norm. Always
69 /// available — it reads only the activation-side fit.
70 pub presence: f64,
71 /// **coupling** (behavioral): support-weighted mean output-Fisher mass of the
72 /// decoder tangent `dg_k/dt`. `None` under a Euclidean /
73 /// no-Fisher provenance (the metric carries no behavioral information, so the
74 /// score is *not available* — not zero, not an error).
75 pub coupling: Option<f64>,
76 /// **presence** normalized to `[0, 1]` across the report's atoms (divided by
77 /// the max presence; `0` if every atom has zero presence).
78 pub presence_normalized: f64,
79 /// **coupling** normalized to `[0, 1]` across the report's atoms (divided by
80 /// the max coupling). `None` whenever coupling itself is unavailable.
81 pub coupling_normalized: Option<f64>,
82 /// The headline: `presence_normalized − coupling_normalized`, the
83 /// "represented but not currently used" discrepancy. High ⇒ thinking it, not
84 /// saying it. `None` when coupling is unavailable (no behavioral axis to
85 /// compare presence against).
86 pub discrepancy: Option<f64>,
87}
88
89impl AtomLensEntry {
90 /// Whether this atom reads as **represented but not currently used** —
91 /// strong activation presence, weak behavioral coupling. Pure classification
92 /// of the already-computed scores; it suppresses nothing.
93 ///
94 /// Returns `false` when coupling is unavailable (no behavioral axis exists to
95 /// declare a discrepancy against).
96 pub fn is_represented_not_used(&self) -> bool {
97 match self.discrepancy {
98 Some(d) => d >= REPRESENTED_NOT_USED_THRESHOLD,
99 None => false,
100 }
101 }
102
103 /// Whether this atom reads as **used** — its behavioral coupling is at least
104 /// as strong as its representational presence (non-positive discrepancy).
105 /// Returns `false` when coupling is unavailable.
106 pub fn is_used(&self) -> bool {
107 match self.discrepancy {
108 Some(d) => d <= USED_THRESHOLD,
109 None => false,
110 }
111 }
112}
113
114/// Discrepancy at or above this flags "represented but not currently used".
115/// Presence and coupling are each normalized to `[0, 1]`, so the discrepancy
116/// lives in `[-1, 1]`; a value this large means presence outruns coupling by a
117/// wide, normalized margin.
118const REPRESENTED_NOT_USED_THRESHOLD: f64 = 0.5;
119
120/// Discrepancy at or below this flags "used" (coupling matches or exceeds
121/// presence).
122const USED_THRESHOLD: f64 = 0.0;
123
124/// The full two-score lens over every atom of a fitted SAE-manifold term.
125#[derive(Clone, Debug, PartialEq)]
126pub struct AtomTwoLensReport {
127 /// One entry per atom, in atom order.
128 pub atoms: Vec<AtomLensEntry>,
129 /// The provenance of the metric the coupling was read through (or would have
130 /// been): `OutputFisher` / `WhitenedStructured` ⇒ coupling available;
131 /// `Euclidean` (or no metric installed) ⇒ coupling unavailable. Echoed so a
132 /// consumer can certify *why* a coupling is `None`.
133 pub coupling_provenance: Option<MetricProvenance>,
134}
135
136impl AtomTwoLensReport {
137 /// Whether the behavioral coupling axis is available at all (i.e. an
138 /// output-Fisher / structured metric was installed). When `false`, every
139 /// entry's `coupling`, `coupling_normalized`, and `discrepancy` are `None`.
140 pub fn coupling_available(&self) -> bool {
141 self.coupling_provenance
142 .is_some_and(metric_carries_behavior)
143 }
144}
145
146/// Does this provenance carry behavioral (output-Fisher) information? Euclidean
147/// does not (it is the isotropic activation-only path); the factored
148/// provenances do.
149fn metric_carries_behavior(p: MetricProvenance) -> bool {
150 match p {
151 MetricProvenance::Euclidean => false,
152 MetricProvenance::OutputFisher { .. }
153 | MetricProvenance::OutputFisherDownstream { .. }
154 | MetricProvenance::BehavioralFisher { .. }
155 | MetricProvenance::WhitenedStructured { .. } => true,
156 }
157}
158
159/// Build the two-score per-atom lens over a fitted [`SaeManifoldTerm`].
160///
161/// `model` is the fitted term (read only). `metric` is the per-row inner product
162/// the coupling is measured through; pass the model's own installed metric
163/// ([`SaeManifoldTerm::row_metric`]) or any metric whose row/output dimensions
164/// match the term. When the metric's provenance is Euclidean (no behavioral
165/// information), the coupling degrades to `None` for every atom — the lens stays
166/// available, only its behavioral axis is absent.
167///
168/// This function is a *pure read*: it never mutates the model, never touches a
169/// loss / criterion / penalty, and the only place the Fisher metric enters is the
170/// [`RowMetric::fisher_mass`] call that produces the (reported) coupling score.
171pub fn atom_two_lens(
172 model: &SaeManifoldTerm,
173 metric: &RowMetric,
174 assignments_override: Option<ArrayView2<'_, f64>>,
175) -> Result<AtomTwoLensReport, String> {
176 let n = model.n_obs();
177 let k = model.k_atoms();
178 let provenance = metric.provenance();
179 // Coupling is only meaningful when the metric carries behavioral
180 // information *and* its dimensions match the term. A mismatched metric (or a
181 // Euclidean one) degrades the behavioral axis to "not available" rather than
182 // erroring — the lens is optional, mirroring the harvest being optional.
183 let coupling_axis_available = metric_carries_behavior(provenance)
184 && metric.n_rows() == n
185 && metric.p_out() == model.output_dim();
186
187 // Per-row assignment masses, computed once. An explicit override must be the
188 // same assignment matrix used by the corresponding reconstruction.
189 let assignments_owned;
190 let assignments = match assignments_override {
191 Some(view) => view,
192 None => {
193 assignments_owned = model.assignment.assignments();
194 assignments_owned.view()
195 }
196 };
197 if assignments.dim() != (n, k) {
198 return Err(format!(
199 "atom_two_lens: assignments shape {:?} must be ({n}, {k})",
200 assignments.dim()
201 ));
202 }
203
204 let mut presence = vec![0.0_f64; k];
205 let mut coupling_raw = vec![0.0_f64; k];
206 let mut any_coupling = vec![false; k];
207
208 for (atom_idx, atom) in model.atoms.iter().enumerate() {
209 // Physical decoder norm, matching the derivatives used by coupling.
210 let decoder_norm = atom.contribution_frobenius_scale();
211
212 let latent_dim = atom.latent_dim();
213
214 let support = SupportMeasure::from_assignment_matrix(assignments, atom_idx)?;
215 let support_mass = support.mass();
216 let mut coupling_sum = 0.0_f64;
217
218 for row in 0..support.len() {
219 let mass = support.weight(row);
220 if !(mass > 0.0) {
221 continue;
222 }
223
224 if coupling_axis_available {
225 // Behavioral coupling on this supported row: the output-Fisher mass
226 // of the decoder tangent dg_k/dt summed over the atom's latent
227 // axes, weighted by the support mass (so a barely-supported row
228 // contributes proportionally less behavioral evidence, matching
229 // the presence weighting). This is the ONLY place the Fisher
230 // metric enters; `fisher_mass` reads no loss / criterion.
231 let mut row_tangent_mass = 0.0_f64;
232 for axis in 0..latent_dim {
233 let dg = atom.decoded_derivative_row(row, axis);
234 let dg_view: ArrayView1<'_, f64> = dg.view();
235 row_tangent_mass += metric.fisher_mass(row, dg_view);
236 }
237 coupling_sum += mass * row_tangent_mass;
238 any_coupling[atom_idx] = true;
239 }
240 }
241
242 let mean_support_mass = if support_mass > 0.0 {
243 support.fisher_n() / support_mass
244 } else {
245 0.0
246 };
247 presence[atom_idx] = mean_support_mass * decoder_norm;
248
249 if coupling_axis_available && support_mass > 0.0 {
250 coupling_raw[atom_idx] = coupling_sum / support_mass;
251 }
252 }
253
254 // Normalize presence across atoms (divide by the max; 0 when all zero).
255 let presence_max = presence.iter().copied().fold(0.0_f64, f64::max);
256 // Normalize coupling across atoms, only over atoms with an available score.
257 let coupling_max = coupling_raw
258 .iter()
259 .zip(any_coupling.iter())
260 .filter(|&(_, &has)| has)
261 .map(|(&c, _)| c)
262 .fold(0.0_f64, f64::max);
263
264 let mut entries = Vec::with_capacity(k);
265 for (atom_idx, atom) in model.atoms.iter().enumerate() {
266 let p = presence[atom_idx];
267 let presence_normalized = if presence_max > 0.0 {
268 p / presence_max
269 } else {
270 0.0
271 };
272
273 let (coupling, coupling_normalized, discrepancy) =
274 if coupling_axis_available && any_coupling[atom_idx] {
275 let c = coupling_raw[atom_idx];
276 let c_norm = if coupling_max > 0.0 {
277 c / coupling_max
278 } else {
279 0.0
280 };
281 (Some(c), Some(c_norm), Some(presence_normalized - c_norm))
282 } else {
283 (None, None, None)
284 };
285
286 entries.push(AtomLensEntry {
287 name: atom.name.clone(),
288 presence: p,
289 coupling,
290 presence_normalized,
291 coupling_normalized,
292 discrepancy,
293 });
294 }
295
296 Ok(AtomTwoLensReport {
297 atoms: entries,
298 coupling_provenance: Some(provenance),
299 })
300}