gam_sae/inference/probe_runner.rs
1//! `ProbeRunner` — the closed loop between the steering primitive
2//! ([`crate::inference::steering`]) and the anytime-valid structure-evidence
3//! ledger ([`gam_terms::inference::structure_evidence`]).
4//!
5//! Both halves are implemented and tested in isolation; nothing wired them into
6//! a runnable experiment loop. This is that bridge. The evidence module decides
7//! WHICH claim to interrogate and HOW MUCH a probe should move the e-process
8//! (`plan_probe_for_contested_claim`); the steering module turns a chosen latent
9//! intervention into the on-manifold activation delta with its dosimetry and
10//! validity radius (`steer_delta`). The runner picks the contested claim, asks
11//! the planner for the experiment, realizes it through the steering primitive,
12//! and feeds the realized dose back into the ledger as anytime-valid evidence.
13//!
14//! # The discrimination coordinate
15//!
16//! For a contested claim about atom `k`, the two hypotheses are "this atom
17//! carries the steering move along its learned surface" (the alternative) versus
18//! "it does not" (the null). The steering primitive measures, in **nats of
19//! output-Fisher KL**, exactly how much behavioral effect a latent move along
20//! atom `k` actually delivers — its `predicted_nats` dose. That dose IS the
21//! expected per-observation log-growth of the deciding e-process under the
22//! alternative (the module docs' "the SAME quadratic form the steering dosimetry
23//! already computes, repurposed"). So each candidate latent move becomes a
24//! one-dimensional [`CandidateProbe`] whose hypothesis disagreement, read through
25//! the identity Fisher, reproduces the realized dose:
26//! `½ (μ₁ − μ₀)ᵀ F (μ₁ − μ₀) = predicted_nats` with `μ₀ = 0`,
27//! `μ₁ = √(2·predicted_nats)`, `F = [[1]]`. No fabricated metric — the real
28//! steering dose flows through the real planner.
29
30use ndarray::array;
31
32use crate::inference::steering::{SteerPlan, steer_delta};
33use crate::manifold::SaeManifoldTerm;
34use gam_problem::RowMetric;
35use gam_terms::inference::structure_evidence::{
36 CandidateProbe, ClaimKind, ProbePlan, StructureLedger, plan_probe_for_contested_claim,
37};
38
39/// The level the contested-claim selection and budget are computed at. Fixed so
40/// a probe can never be shopped across α after seeing the evidence (mirrors
41/// [`gam_terms::inference::structure_evidence::AtomBirthGate`]'s construction-time α).
42const PROBE_DESIGN_ALPHA: f64 = 0.05;
43
44/// Latent step length each candidate probe moves the contested atom by, per
45/// axis, away from its fitted representative coordinate. A modest step keeps the
46/// move inside the surface's local regime; the steering primitive reports the
47/// validity radius so an over-long move is flagged, never silently clipped.
48const PROBE_LATENT_STEP: f64 = 0.5;
49
50/// A planned probe carried alongside its realized steering intervention: the
51/// experiment-design output (`plan`), the on-manifold activation delta and
52/// dosimetry the steering primitive produced for it (`steer`), and the realized
53/// behavioral dose in nats once it has been measured (`realized_nats`, `None`
54/// until [`ProbeRunner::absorb`] banks it).
55#[derive(Clone, Debug)]
56pub struct RealizedProbe {
57 /// The experiment plan for the most contested claim: which candidate probe,
58 /// its expected per-observation log-growth, and the resolution budget.
59 pub plan: ProbePlan,
60 /// The realized steering intervention for the chosen candidate: the
61 /// activation-space δ, predicted dose, validity radius, off-manifold guard.
62 pub steer: SteerPlan,
63 /// The realized behavioral dose in nats once observed, banked by
64 /// [`ProbeRunner::absorb`] into the claim's e-process. `None` at design time.
65 pub realized_nats: Option<f64>,
66}
67
68/// The closed-loop probe runner over one fitted SAE-manifold term and its
69/// per-row output-Fisher metric.
70pub struct ProbeRunner<'a> {
71 /// The fitted term whose atoms the probes steer (read only).
72 pub term: &'a SaeManifoldTerm,
73 /// The per-row output-Fisher inner product the dose is measured through.
74 pub metric: &'a RowMetric,
75}
76
77impl<'a> ProbeRunner<'a> {
78 /// Design the next probe for the most contested claim in `ledger`.
79 ///
80 /// Picks the contested claim with the LEAST evidence (the one furthest from
81 /// the `1/α` Ville threshold — the most in need of interrogation), reads the
82 /// atom it concerns, and builds candidate steering moves along that atom's
83 /// latent axes from its fitted representative coordinate. Each candidate is
84 /// realized through [`steer_delta`] so its actual output-Fisher dose is
85 /// known; the candidates are handed to [`plan_probe_for_contested_claim`],
86 /// which selects the most discriminating one and converts the claim's
87 /// current evidence into a remaining budget. The selected candidate's
88 /// already-computed [`SteerPlan`] rides back in the result.
89 pub fn design_next(&self, ledger: &StructureLedger) -> Result<RealizedProbe, String> {
90 let (claim_idx, atom_k) = self.most_contested_atom_claim(ledger)?;
91 let claim_kind = &ledger.claims()[claim_idx].kind;
92 let current_log_e = ledger.claims()[claim_idx].evidence.current_e_value_log();
93
94 let candidates = self.candidate_steers(atom_k)?;
95 if candidates.is_empty() {
96 return Err(format!(
97 "ProbeRunner::design_next: atom {atom_k} (claim {claim_idx}) admits no steering \
98 candidate (zero latent dimension or no installed basis evaluator)"
99 ));
100 }
101
102 // Convert each realized steering dose into a one-dimensional candidate
103 // probe whose hypothesis disagreement, read through the identity Fisher,
104 // equals that dose: ½(μ₁)² = predicted_nats ⇒ μ₁ = √(2·dose).
105 let probes: Vec<CandidateProbe> = candidates
106 .iter()
107 .map(|steer| {
108 let objective = self.probe_objective(claim_kind, steer);
109 CandidateProbe {
110 delta: steer.delta.clone(),
111 predicted_mean_null: array![0.0],
112 predicted_mean_alt: array![(2.0 * objective).sqrt()],
113 }
114 })
115 .collect();
116 let fisher = array![[1.0]];
117
118 let plan =
119 plan_probe_for_contested_claim(&probes, &fisher, PROBE_DESIGN_ALPHA, current_log_e)
120 .ok_or_else(|| {
121 format!(
122 "ProbeRunner::design_next: no candidate probe discriminates the hypotheses \
123 for atom {atom_k} (every reachable steering move delivers zero design \
124 objective — the claim is undecidable by steering, a finding not a failure)"
125 )
126 })?;
127
128 let steer = candidates.into_iter().nth(plan.probe).ok_or_else(|| {
129 format!(
130 "ProbeRunner::design_next: planner selected candidate {} of {} for atom \
131 {atom_k}",
132 plan.probe,
133 probes.len()
134 )
135 })?;
136
137 Ok(RealizedProbe {
138 plan,
139 steer,
140 realized_nats: None,
141 })
142 }
143
144 /// Absorb a realized probe outcome, updating the ledger's evidence for the
145 /// probe's claim.
146 ///
147 /// `realized_nats` is the dose the probe actually delivered when run (the
148 /// observed output-Fisher KL of the steered response). Under the local
149 /// Gaussian output model the alternative-vs-null log-likelihood ratio of one
150 /// such observation is exactly that dose, so it routes straight into the
151 /// claim's e-process through [`StructureLedger::absorb_probe_outcome`] as
152 /// `log(alt) − log(null) = realized_nats − 0`. The contract its docstring
153 /// requires — both hypotheses' densities frozen before the outcome — holds
154 /// here: the steering plan (and thus both predictions) was fixed at design
155 /// time, before any outcome existed.
156 pub fn absorb(&self, ledger: &mut StructureLedger, probe: &RealizedProbe, realized_nats: f64) {
157 let Ok((claim_idx, _)) = self.claim_for_steer(ledger, &probe.steer) else {
158 return;
159 };
160 // The realized log-LR of one observation under the local Gaussian model
161 // is the delivered dose; the null density contributes log-likelihood 0.
162 ledger
163 .absorb_probe_outcome(claim_idx, realized_nats, 0.0)
164 .ok();
165 }
166
167 /// The ledger index and atom index of the contested claim with the LEAST
168 /// accumulated evidence (the one furthest from certification). Only claims
169 /// naming a concrete atom — [`ClaimKind::AtomExists`] and
170 /// [`ClaimKind::GeometryKind`] — are steerable; binding-edge and custom
171 /// claims have no single atom to drive and are skipped.
172 fn most_contested_atom_claim(
173 &self,
174 ledger: &StructureLedger,
175 ) -> Result<(usize, usize), String> {
176 let mut best: Option<(usize, usize, f64)> = None;
177 for (idx, claim) in ledger.claims().iter().enumerate() {
178 let Some(atom_k) = steerable_atom(&claim.kind) else {
179 continue;
180 };
181 if atom_k >= self.term.k_atoms() {
182 continue;
183 }
184 let log_e = claim.evidence.current_e_value_log();
185 match best {
186 Some((_, _, best_log_e)) if best_log_e <= log_e => {}
187 _ => best = Some((idx, atom_k, log_e)),
188 }
189 }
190 best.map(|(idx, atom_k, _)| (idx, atom_k)).ok_or_else(|| {
191 "ProbeRunner: ledger has no contested claim naming a steerable atom in this term"
192 .to_string()
193 })
194 }
195
196 /// Find the ledger claim a realized steer belongs to: the contested
197 /// steerable claim whose atom matches the steer's atom index, least-evidence
198 /// first (the same selection `design_next` used).
199 fn claim_for_steer(
200 &self,
201 ledger: &StructureLedger,
202 steer: &SteerPlan,
203 ) -> Result<(usize, usize), String> {
204 let mut best: Option<(usize, f64)> = None;
205 for (idx, claim) in ledger.claims().iter().enumerate() {
206 if steerable_atom(&claim.kind) != Some(steer.atom) {
207 continue;
208 }
209 let log_e = claim.evidence.current_e_value_log();
210 match best {
211 Some((_, best_log_e)) if best_log_e <= log_e => {}
212 _ => best = Some((idx, log_e)),
213 }
214 }
215 best.map(|(idx, _)| (idx, steer.atom))
216 .ok_or_else(|| format!("ProbeRunner: no claim names steered atom {}", steer.atom))
217 }
218
219 /// Design objective for a realized steering candidate. Existence claims use
220 /// the behavioral KL dose directly. Geometry adjudication is different: the
221 /// curvature signal scales as observed chart coverage to the fourth power,
222 /// so a token spent at an already-covered coordinate buys essentially no new
223 /// curvature-certification power. For geometry claims the candidate score is
224 /// therefore the expected increase in `extent^4` of the observed chart, per
225 /// probe token (all candidates here cost one token), rather than raw dose.
226 fn probe_objective(&self, claim_kind: &ClaimKind, steer: &SteerPlan) -> f64 {
227 match claim_kind {
228 ClaimKind::GeometryKind { .. } => self.chart_extent_fourth_gain(steer),
229 ClaimKind::AtomExists { .. }
230 | ClaimKind::BindingEdge { .. }
231 | ClaimKind::Custom { .. } => steer.predicted_nats.unwrap_or(0.0).max(0.0),
232 }
233 }
234
235 /// Increase in the atom's observed latent chart extent to the fourth power
236 /// after adding this probe endpoint. The extent is the root-mean-square axis
237 /// range, which is zero only for a collapsed chart and grows when any latent
238 /// direction obtains genuinely wider coverage.
239 fn chart_extent_fourth_gain(&self, steer: &SteerPlan) -> f64 {
240 let coords = self.term.assignment.coords[steer.atom].as_matrix();
241 let d = steer.t_to.len();
242 if d == 0 {
243 return 0.0;
244 }
245 let mut extent_sq = 0.0_f64;
246 let mut extended_extent_sq = 0.0_f64;
247 for axis in 0..d {
248 let mut lo = f64::INFINITY;
249 let mut hi = f64::NEG_INFINITY;
250 for row in 0..coords.nrows() {
251 let t = coords[[row, axis]];
252 if t.is_finite() {
253 lo = lo.min(t);
254 hi = hi.max(t);
255 }
256 }
257 if !lo.is_finite() || !hi.is_finite() {
258 lo = steer.t_from[axis];
259 hi = steer.t_from[axis];
260 }
261 let range = (hi - lo).max(0.0);
262 extent_sq += range * range;
263
264 let to = steer.t_to[axis];
265 let extended_range = (hi.max(to) - lo.min(to)).max(0.0);
266 extended_extent_sq += extended_range * extended_range;
267 }
268 let inv_d = 1.0 / d as f64;
269 let extent_fourth = (extent_sq * inv_d).powi(2);
270 let extended_extent_fourth = (extended_extent_sq * inv_d).powi(2);
271 (extended_extent_fourth - extent_fourth).max(0.0)
272 }
273
274 /// Build the realized steering candidates for atom `atom_k`: a positive and
275 /// negative [`PROBE_LATENT_STEP`] move along each latent axis, from the
276 /// atom's fitted representative (most-active-row) coordinate. Each move is
277 /// realized through [`steer_delta`] so the planner sees its true dose.
278 fn candidate_steers(&self, atom_k: usize) -> Result<Vec<SteerPlan>, String> {
279 let t0 = self.representative_coordinate(atom_k);
280 let d = t0.len();
281 let mut out = Vec::with_capacity(2 * d);
282 for axis in 0..d {
283 for &sign in &[1.0_f64, -1.0_f64] {
284 let mut t_to = t0.clone();
285 t_to[axis] += sign * PROBE_LATENT_STEP;
286 out.push(steer_delta(self.term, self.metric, atom_k, &t0, &t_to)?);
287 }
288 }
289 Ok(out)
290 }
291
292 /// Atom `atom_k`'s fitted latent coordinate at its most-active row — the
293 /// representative operating point a probe perturbs away from. Falls back to
294 /// the origin when the atom is active on no row.
295 fn representative_coordinate(&self, atom_k: usize) -> Vec<f64> {
296 let assignments = self.term.assignment.assignments();
297 let n = self.term.n_obs();
298 let mut best_row = 0usize;
299 let mut best_mass = f64::NEG_INFINITY;
300 for row in 0..n {
301 let mass = assignments[[row, atom_k]];
302 if mass > best_mass {
303 best_mass = mass;
304 best_row = row;
305 }
306 }
307 self.term.assignment.coords[atom_k].row(best_row).to_vec()
308 }
309}
310
311/// The atom a structural claim is about, when it is one a single steering move
312/// can interrogate. `None` for claims with no single steerable atom (binding
313/// edges concern a pair; custom claims name no atom).
314fn steerable_atom(kind: &ClaimKind) -> Option<usize> {
315 match kind {
316 ClaimKind::AtomExists { atom } | ClaimKind::GeometryKind { atom, .. } => Some(*atom),
317 ClaimKind::BindingEdge { .. } | ClaimKind::Custom { .. } => None,
318 }
319}